diff --git a/reviewboard/filemanager/admin.py b/reviewboard/filemanager/admin.py
new file mode 100644
index 0000000000000000000000000000000000000000..81380115155b11d321b5f9036535a7b86b52719c
--- /dev/null
+++ b/reviewboard/filemanager/admin.py
@@ -0,0 +1,34 @@
+from django.contrib import admin
+from django.utils.translation import ugettext_lazy as _
+
+from reviewboard.filemanager.models import UploadedFile
+from reviewboard.reviews.forms import DefaultReviewerForm
+from reviewboard.reviews.models import Comment, DefaultReviewer, Group, \
+                                       Review, ReviewRequest, \
+                                       ReviewRequestDraft, Screenshot, \
+                                       ScreenshotComment, UploadedFileComment
+
+
+class UploadedFileAdmin(admin.ModelAdmin):
+    list_display = ('file', 'caption', 'review_request_id')
+    list_display_links = ('file', 'caption')
+    search_fields = ('caption',)
+
+    def review_request_id(self, obj):
+        return obj.review_request.get().id
+    review_request_id.short_description = _('Review request ID')
+
+
+class UploadedFileCommentAdmin(admin.ModelAdmin):
+    list_display = ('text', 'file', 'review_request_id', 'timestamp')
+    list_filter = ('timestamp',)
+    search_fields = ('caption', 'file')
+    raw_id_fields = ('file', 'reply_to')
+
+    def review_request_id(self, obj):
+        return obj.review.get().review_request.id
+    review_request_id.short_description = _('Review request ID')
+
+
+admin.site.register(UploadedFile, UploadedFileAdmin)
+admin.site.register(UploadedFileComment, UploadedFileCommentAdmin)
diff --git a/reviewboard/filemanager/forms.py b/reviewboard/filemanager/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..de3b9a21aa75c61838908d5c984ee48aa5bfa688
--- /dev/null
+++ b/reviewboard/filemanager/forms.py
@@ -0,0 +1,60 @@
+import logging
+import re
+
+from django import forms
+from django.contrib.admin.widgets import FilteredSelectMultiple
+from django.utils.translation import ugettext as _
+from djblets.util.misc import get_object_or_none
+
+from reviewboard.diffviewer import forms as diffviewer_forms
+from reviewboard.diffviewer.models import DiffSet
+from reviewboard.filemanager.models import UploadedFile
+from reviewboard.reviews.errors import OwnershipError
+from reviewboard.reviews.models import DefaultReviewer, ReviewRequest, \
+                                       ReviewRequestDraft, UploadedFileComment
+from reviewboard.scmtools.errors import SCMError, ChangeNumberInUseError, \
+                                        InvalidChangeNumberError, \
+                                        ChangeSetError
+from reviewboard.scmtools.models import Repository
+from reviewboard.site.models import LocalSite
+
+
+class UploadFileForm(forms.Form):
+    """A form that handles uploading of new files.
+
+    A file takes a path argument and optionally a caption.
+    """
+    caption = forms.CharField(required=False)
+    path = forms.FileField(required=True)
+
+    def create(self, file, review_request):
+        uploaded_file = UploadedFile(caption=self.cleaned_data['caption'])
+        uploaded_file.file.save(file.name, file, save=True)
+
+        review_request.files.add(uploaded_file)
+
+        draft = ReviewRequestDraft.create(review_request)
+        draft.files.add(uploaded_file)
+        draft.save()
+
+        return uploaded_file
+
+
+class CommentFileForm(forms.Form):
+    """A form that handles commenting on a file."""
+    review = forms.CharField(
+        widget=forms.Textarea(attrs={'rows':'8','cols':'70'}))
+
+    def create(self, file, review_request):
+        comment = UploadedFileComment(text=self.cleaned_data['review'],
+                                      file=file)
+
+        comment.timestamp = datetime.now()
+        comment.save(save=True)
+        review_request.files.add(file)
+
+        draft = ReviewRequestDraft.create(review_request)
+        draft.file_comments.add(comment)
+        draft.save()
+
+        return comment
diff --git a/reviewboard/filemanager/models.py b/reviewboard/filemanager/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..9cad05754ebac7b2b5ca3716cefe58ae4f4b01e8
--- /dev/null
+++ b/reviewboard/filemanager/models.py
@@ -0,0 +1,56 @@
+import os
+import re
+from datetime import datetime
+
+from django.contrib.auth.models import User
+from django.core.urlresolvers import reverse
+from django.db import connection, models, transaction
+from django.db.models import F, Q, permalink
+from django.utils.html import escape
+from django.utils.safestring import mark_safe
+from django.utils.translation import ugettext_lazy as _
+
+from djblets.util.db import ConcurrencyManager
+from djblets.util.decorators import root_url
+from djblets.util.fields import CounterField, ModificationTimestampField
+from djblets.util.misc import get_object_or_none
+from djblets.util.templatetags.djblets_images import crop_image, thumbnail
+
+from reviewboard.changedescs.models import ChangeDescription
+from reviewboard.diffviewer.models import DiffSet, DiffSetHistory, FileDiff
+from reviewboard.reviews.errors import PermissionError
+from reviewboard.reviews.signals import review_request_published, \
+                                        reply_published, review_published
+from reviewboard.scmtools.errors import EmptyChangeSetError, \
+                                        InvalidChangeNumberError
+from reviewboard.scmtools.models import Repository
+from reviewboard.site.models import LocalSite
+
+
+class UploadedFile(models.Model):
+    """A file associated with a review request.
+
+    Like diffs, a file can have comments associated with it.
+    These comments are of type :model:`reviews.FileComment`.
+    """
+    caption = models.CharField(_("caption"), max_length=256, blank=True)
+    draft_caption = models.CharField(_("draft caption"),
+                                     max_length=256, blank=True)
+    file = models.FileField(_("file"),
+                              upload_to=os.path.join('uploaded', 'files',
+                                                     '%Y', '%m', '%d'))
+
+    def get_path(self):
+        """Returns the file path for downloading purposes."""
+        return self.file.url
+
+    def get_title(self):
+        """Returns the file title for display purposes"""
+        return os.path.basename(self.file.name)
+
+    def __unicode__(self):
+        return self.caption
+
+    def get_absolute_url(self):
+        return self.file.url
+
diff --git a/reviewboard/htdocs/media/rb/css/reviews.css b/reviewboard/htdocs/media/rb/css/reviews.css
index 3f5f0cd96da6cd594084735b76da8fbe09a66a8d..59a750c29398c0cbadfc2242aa0436b34fe03892 100644
--- a/reviewboard/htdocs/media/rb/css/reviews.css
+++ b/reviewboard/htdocs/media/rb/css/reviews.css
@@ -607,7 +607,6 @@
 }
 
 
-
 /****************************************************************************
  * Review Form
  ****************************************************************************/
@@ -778,6 +777,26 @@
 
 
 /****************************************************************************
+ * List of Attached Files
+ ****************************************************************************/
+#file-list {
+  margin: 2px;
+  padding: 0.5em;
+}
+
+#file-list.dragover {
+  border: 2px green dashed;
+  display: block;
+  margin: 0;
+}
+
+#file-list h1.drop-indicator {
+  clear: both;
+  text-align: center;
+}
+
+
+/****************************************************************************
  * Comment detail dialog
  ****************************************************************************/
 #comment-detail {
diff --git a/reviewboard/htdocs/media/rb/js/datastore.js b/reviewboard/htdocs/media/rb/js/datastore.js
index be93bb44fb9ed1e8745b164f80de10a6d08b6420..8952e2187ab5b7735dfc65e57377bcc90e35cf37 100644
--- a/reviewboard/htdocs/media/rb/js/datastore.js
+++ b/reviewboard/htdocs/media/rb/js/datastore.js
@@ -447,6 +447,14 @@ $.extend(RB.ReviewRequest.prototype, {
         return new RB.Screenshot(this, screenshot_id);
     },
 
+    createUploadedFile: function() {
+        return new RB.UploadedFile(this);
+    },
+
+    createFileComment: function(file_id) {
+        return new RB.FileComment(this, file_id);
+    },
+
     /*
      * Ensures that the review request's state is loaded.
      *
@@ -663,6 +671,10 @@ $.extend(RB.Review.prototype, {
                                         width, height);
     },
 
+    createFileComment: function(id, file_id) {
+        return new RB.FileComment(this, id, file_id);
+    },
+
     createReply: function() {
         if (this.draft_reply == null) {
             this.draft_reply = new RB.ReviewReply(this);
@@ -1016,6 +1028,328 @@ $.extend(RB.ReviewReply.prototype, {
 });
 
 
+RB.UploadedFile = function(review_request, id) {
+    this.review_request = review_request;
+    this.id = id;
+    this.caption = null;
+    this.thumbnail_url = null;
+    this.path = null;
+    this.url = null;
+    this.loaded = false;
+
+    return this;
+}
+
+
+$.extend(RB.UploadedFile.prototype, {
+    setFile: function(file) {
+        this.uploaded_file = file;
+    },
+
+    setForm: function(form) {
+        this.form = form;
+    },
+
+    ready: function(on_done) {
+        if (this.loaded && this.id) {
+            on_done.apply(this, arguments);
+        } else {
+            this._load(on_done);
+        }
+    },
+
+    save: function(options) {
+        options = $.extend(true, {
+            success: function() {},
+            error: function() {}
+        }, options);
+
+        if (this.id) {
+            var data = {};
+            if (this.caption != null) {
+                data.caption = this.caption;
+            }
+
+            var self = this;
+            this.ready(function() {
+                rbApiCall({
+                    type: "PUT",
+                    url: self.url,
+                    data: data,
+                    buttons: options.buttons,
+                    success: function(rsp) {
+                        self._loadDataFromResponse(rsp);
+
+                        if ($.isFunction(options.success)) {
+                            options.success(rsp);
+                        }
+                    }
+                });
+            });
+        } else {
+            if (this.form) {
+                this._saveForm(options);
+            } else if (this.file) {
+                this._saveFile(options);
+            } else {
+                options.error("No data has been set for this screenshot. " +
+                              "This is a script error. Please report it.");
+            }
+        }
+    },
+
+    deleteUploadedFile: function() {
+        var self = this;
+
+        self.ready(function() {
+            if (self.loaded) {
+                rbApiCall({
+                    type: "DELETE",
+                    url: self.url,
+                    success: function() {
+                        $.event.trigger("deleted", null, self);
+                        self._deleteAndDestruct();
+                    }
+                });
+            }
+        });
+    },
+
+    _load: function(on_done) {
+        if (!this.id) {
+            on_done.apply(this, arguments);
+            return;
+        }
+
+        var self = this;
+
+        self.review_request.ready(function() {
+            rbApiCall({
+                type: "GET",
+                url: self.review_request.links.file.href + self.id + "/",
+                success: function(rsp, status) {
+                    if (status != 404) {
+                        self._loadDataFromResponse(rsp);
+                    }
+
+                    on_done.apply(this, arguments);
+                }
+            });
+        });
+    },
+
+    _loadDataFromResponse: function(rsp) {
+        this.id = rsp.uploaded_file.id;
+        this.caption = rsp.uploaded_file.caption;
+        this.thumbnail_url = rsp.uploaded_file.thumbnail_url;
+        this.path = rsp.uploaded_file.path;
+        this.url = rsp.uploaded_file.links.self.href;
+        this.loaded = true;
+    },
+
+    _saveForm: function(options) {
+        this._saveApiCall(options.success, options.error, {
+            buttons: options.buttons,
+            form: this.form
+        });
+    },
+
+    _saveFile: function(options) {
+        var boundary = "-----multipartformboundary" + new Date().getTime();
+        var blob = "";
+        blob += "--" + boundary + "\r\n";
+        blob += 'Content-Disposition: form-data; name="path"; ' +
+                'filename="' + this.file.name + '"\r\n';
+        blob += 'Content-Type: application/octet-stream\r\n';
+        blob += '\r\n';
+        blob += this.file.getAsBinary();
+        blob += '\r\n';
+        blob += "--" + boundary + "--\r\n";
+        blob += '\r\n';
+
+        this._saveApiCall(options.success, options.error, {
+            buttons: options.buttons,
+            data: blob,
+            processData: false,
+            contentType: "multipart/form-data; boundary=" + boundary,
+            xhr: function() {
+                var xhr = $.ajaxSettings.xhr()
+                xhr.send = function(data) {
+                    xhr.sendAsBinary(blob);
+                };
+
+                return xhr;
+            }
+        });
+    },
+
+    _saveApiCall: function(onSuccess, onError, options) {
+        var self = this;
+        self.review_request.ready(function() {
+            rbApiCall($.extend(options, {
+                url: self.review_request.links.uploaded_files.href,
+                success: function(rsp) {
+                    if (rsp.stat == "ok") {
+                        self._loadDataFromResponse(rsp);
+
+                        if ($.isFunction(onSuccess)) {
+                            onSuccess(rsp, rsp.uploaded_file);
+                        }
+                    } else if ($.isFunction(onError)) {
+                        onError(rsp, rsp.err.msg);
+                    }
+                }
+            }));
+        });
+    },
+
+    _deleteAndDestruct: function() {
+        $.event.trigger("destroyed", null, this);
+    }
+});
+
+
+RB.UploadedFileCommentReply = function(reply, id, reply_to_id) {
+    this.id = id;
+    this.reply = reply;
+    this.text = "";
+    this.reply_to_id = reply_to_id;
+    this.loaded = false;
+    this.url = null;
+
+    return this;
+}
+
+$.extend(RB.UploadedFileCommentReply.prototype, {
+    ready: function(on_ready) {
+        if (this.loaded) {
+            on_ready.apply(this, arguments);
+        } else {
+            this._load(on_ready);
+        }
+    },
+
+    /*
+     * Sets the current text in the comment block.
+     *
+     * @param {string} text  The new text to set.
+     */
+    setText: function(text) {
+        this.text = text;
+        $.event.trigger("textChanged", null, this);
+    },
+
+    /*
+     * Saves the comment on the server.
+     */
+    save: function(options) {
+        var self = this;
+        options = options || {};
+
+        self.ready(function() {
+            self.reply.ensureCreated(function() {
+                var type;
+                var url;
+                var data = {
+                    text: self.text
+                };
+
+                if (self.loaded) {
+                    type = "PUT";
+                    url = self.url;
+                } else {
+                    data.reply_to_id = self.reply_to_id;
+                    url = self.reply.links.file_comments.href;
+                }
+
+                rbApiCall({
+                    type: type,
+                    url: url,
+                    data: data,
+                    success: function(rsp) {
+                        self._loadDataFromResponse(rsp);
+
+                        $.event.trigger("saved", null, self);
+
+                        if ($.isFunction(options.success)) {
+                            options.success();
+                        }
+                    }
+                });
+            });
+        });
+    },
+
+    /*
+     * Deletes the comment from the server.
+     */
+    deleteComment: function() {
+        var self = this;
+
+        self.ready(function() {
+            if (self.loaded) {
+                rbApiCall({
+                    type: "DELETE",
+                    url: self.url,
+                    success: function() {
+                        $.event.trigger("deleted", null, self);
+                        self._deleteAndDestruct();
+                    }
+                });
+            } else {
+                self._deleteAndDestruct();
+            }
+        });
+    },
+
+    deleteIfEmpty: function() {
+        if (this.text = "") {
+            this.deleteComment();
+        }
+    },
+
+    _deleteAndDestruct: function() {
+        $.event.trigger("destroyed", null, this);
+    },
+
+    _load: function(on_done) {
+        var self = this;
+
+        if (!self.id) {
+            on_done.apply(this, arguments);
+            return;
+        }
+
+        self.reply.ready(function() {
+            if (!self.reply.loaded) {
+                on_done.apply(this, arguments);
+                return;
+            }
+
+            rbApiCall({
+                type: "GET",
+                url: self.reply.links.file_comments.href + self.id + "/",
+                success: function(rsp, status) {
+                    if (status != 404) {
+                        self._loadDataFromResponse(rsp);
+                    }
+
+                    on_done.apply(this, arguments);
+                },
+            });
+        });
+    },
+
+    _loadDataFromResponse: function(rsp) {
+        this.id = rsp.file_comment.id;
+        this.text = rsp.file_comment.text;
+        this.links = rsp.file_comment.links;
+        this.url = rsp.file_comment.links.self.href;
+        this.loaded = true;
+    }
+});
+
+
 RB.Screenshot = function(review_request, id) {
     this.review_request = review_request;
     this.id = id;
@@ -1087,7 +1421,7 @@ $.extend(RB.Screenshot.prototype, {
         }
     },
 
-    deleteScreenshot: function() {
+    deleteFile: function() {
         var self = this;
 
         self.ready(function() {
@@ -1355,6 +1689,160 @@ $.extend(RB.ScreenshotComment.prototype, {
 });
 
 
+RB.FileComment = function(review, file_id, id) {
+    this.id = id;
+    this.review = review;
+    this.file_id = file_id;
+    this.text = "";
+    this.loaded = false;
+    this.url = null;
+    return this;
+}
+
+$.extend(RB.FileComment.prototype, {
+    ready: function(on_ready) {
+        if (this.loaded) {
+            on_ready.apply(this, arguments);
+        } else {
+            this._load(on_ready);
+        }
+    },
+
+    /*
+     * Sets the current text in the comment block.
+     *
+     * @param {string} text  The new text to set.
+     */
+    setText: function(text) {
+        this.text = text;
+        $.event.trigger("textChanged", null, this);
+    },
+
+    setForm: function(form) {
+        this.form = form;
+    },
+
+    _saveForm: function(options) {
+        this._saveApiCall(options.success, options.error, {
+            buttons: options.buttons,
+            form: this.form
+        });
+    },
+
+    /*
+     * Saves the comment on the server.
+     */
+    save: function(options) {
+        var self = this;
+
+        options = $.extend({
+            success: function() {}
+        }, options);
+
+        self.ready(function() {
+            self.review.ensureCreated(function() {
+                var type;
+                var url;
+                var data = {
+                    text: self.text,
+                };
+
+                if (self.loaded) {
+                    type = "PUT";
+                    url = self.url;
+                } else {
+                    data.file_id = self.file_id;
+                    url = self.review.links.file_comments.href;
+                }
+
+                rbApiCall({
+                    type: type,
+                    url: url,
+                    data: data,
+                    success: function(rsp) {
+                        self._loadDataFromResponse(rsp);
+                        $.event.trigger("saved", null, self);
+                        options.success();
+                    }
+                });
+            });
+        });
+    },
+
+    /*
+     * Deletes the comment from the server.
+     */
+    deleteComment: function() {
+        var self = this;
+
+        self.ready(function() {
+            if (self.loaded) {
+                rbApiCall({
+                    type: "DELETE",
+                    url: self.url,
+                    success: function() {
+                        $.event.trigger("deleted", null, self);
+                        self._deleteAndDestruct();
+                    }
+                });
+            } else {
+                this._deleteAndDestruct();
+            }
+        });
+    },
+
+    deleteIfEmpty: function() {
+        if (this.text != "") {
+            return;
+        }
+
+        this.deleteComment();
+    },
+
+    _deleteAndDestruct: function() {
+        $.event.trigger("destroyed", null, this);
+    },
+
+    _load: function(on_done) {
+            var self = this;
+
+            if (!self.id) {
+                    on_done.apply(this, arguments);
+                    return;
+            }
+
+            self.review.ready(function() {
+                    if (!self.review.loaded) {
+                    on_done.apply(this, arguments);
+                    return;
+             }
+
+            rbApiCall({
+                type: "GET",
+                url: self.review.links.uploaded_file_comments.href +
+                        self.id + "/",
+                success: function(rsp, status) {
+                    if (status != 404) {
+                        self._loadDataFromResponse(rsp);
+                    }
+
+                    on_done.apply(this, arguments);
+
+                },
+            });
+        });
+    },
+
+    _loadDataFromResponse: function(rsp) {
+            this.id = rsp.file_comment.id;
+            this.text = rsp.file_comment.text;
+            this.links = rsp.file_comment.links;
+            this.url = rsp.file_comment.links.self.href;
+            this.loaded = true;
+    }
+});
+
+
 RB.ScreenshotCommentReply = function(reply, id, reply_to_id) {
     this.id = id;
     this.reply = reply;
diff --git a/reviewboard/htdocs/media/rb/js/reviews.js b/reviewboard/htdocs/media/rb/js/reviews.js
index 17b70d317363f760067740428e943851bbeb7c1f..b08415d6bbf7214c9c7b455f9b11bb228b55fc43 100644
--- a/reviewboard/htdocs/media/rb/js/reviews.js
+++ b/reviewboard/htdocs/media/rb/js/reviews.js
@@ -415,6 +415,10 @@ $.fn.commentSection = function(review_id, context_id, context_type) {
                         obj = new RB.ScreenshotCommentReply(review_reply, null,
                                                             context_id);
                         obj.setText(value);
+                    } else if (context_type == "file_comment") {
+                        obj = new RB.UploadedFileCommentReply(review_reply, null,
+                                                            context_id);
+                        obj.setText(value);
                     } else {
                         /* Shouldn't be reached. */
                         return;
@@ -832,14 +836,18 @@ $.fn.commentDlg = function() {
     this.handleResize = function() {
         var width = self.width();
         var height = self.height();
+
         var formWidth = width - draftForm.getExtents("bp", "lr");
         var boxHeight = height;
         var commentsWidth = 0;
 
+        /*Joining seems to cause problems for file-uploader*/
         if (commentsPane.is(":visible")) {
             commentsPane
-                .width(COMMENTS_BOX_WIDTH - commentsPane.getExtents("bp", "lr"))
-                .height(boxHeight - commentsPane.getExtents("bp", "tb"))
+                .width(COMMENTS_BOX_WIDTH - commentsPane.getExtents("bp", "lr"));
+            commentsPane
+                .height(boxHeight - commentsPane.getExtents("bp", "tb"));
+            commentsPane
                 .move(0, 0, "absolute");
 
             commentsList.height(commentsPane.height() -
@@ -850,9 +858,13 @@ $.fn.commentDlg = function() {
             formWidth -= commentsWidth;
         }
 
+        /* Caused an error combining all of these together in firefox for
+         * file-uploader */
+        draftForm
+            .width(formWidth);
+        draftForm
+            .height(boxHeight - draftForm.getExtents("bp", "tb"));
         draftForm
-            .width(formWidth)
-            .height(boxHeight - draftForm.getExtents("bp", "tb"))
             .move(commentsWidth, 0, "absolute");
 
         var textFieldPos = textField.position();
@@ -1024,7 +1036,8 @@ $.reviewForm = function(review) {
 /*
  * Adds inline editing capabilities to a comment in the review form.
  *
- * @param {object} comment  A RB.DiffComment or RB.ScreenshotComment instance
+ * @param {object} comment  A RB.DiffComment, RB.UploadedFileComment
+ *                          or RB.ScreenshotComment instance
  *                          to store the text on and save.
  */
 $.fn.reviewFormCommentEditor = function(comment) {
@@ -1184,6 +1197,75 @@ $.newScreenshotThumbnail = function(screenshot) {
     return container.insertBefore(thumbnails.find("br"));
 };
 
+/*
+ * Adds a file to the uploaded files list.
+ *
+ * If an UploadedFile object is given, then this will display the
+ * file data. Otherwise, this will display a placeholder.
+ *
+ * @param {object} uploadedFile  The optional file to display.
+ *
+ * @return {jQuery} The root file-list div.
+ */
+$.fileDisplay = function(uploadedFile) {
+    var container = $("<div/>")
+        .addClass("file-container");
+
+    var body = $("<dd/>")
+        .appendTo(container);
+
+    if (uploadedFile) {
+        
+        var captionArea = $("<label>"+uploadedFile.title+"</label>")
+            .attr({
+                "for": "uploaded_file_"+uploadedFile.id+"_caption"
+            });
+
+        body.append(captionArea);
+        captionArea
+            .append($("<a>Review File</a>")
+                .addClass("file-review")
+                .attr({
+                    href: '#',
+                    id: uploadedFile.id
+                })
+            )
+            .append($("<a>Download File</a>")
+                .attr({
+                    href: uploadedFile.url,
+                })
+            )
+            .append($("<a/>")
+                .attr("href", uploadedFile.file_url + "delete/")
+                .append($("<img/>")
+                    .attr({
+                        src: MEDIA_URL + "rb/images/delete.png?" +
+                             MEDIA_SERIAL,
+                        alt: "Delete File"
+                    })
+                )
+            );
+
+        body.append($("<pre>")
+                .addClass("editable")
+                .addClass("file-editable")
+                .attr({
+                    id: "uploaded_file_"+uploadedFile.id+"_caption"
+                })
+                .append(uploadedFile.caption)
+        );
+
+        container.find(".editable").reviewRequestFieldEditor()
+    } else {
+        body.addClass("loading");
+
+        body.append("&nbsp;");
+    }
+
+    var thumbnails = $("#file-list");
+    $(thumbnails.parent()[0]).show();
+    return container.insertBefore(thumbnails.find("br"));
+};
 
 /*
  * Registers for updates to the review request. This will cause a pop-up
@@ -1490,6 +1572,26 @@ function initScreenshotDnD() {
             }
         });
     }
+
+    function uploadFile(file) {
+        /* Create a temporary file listing. */
+        var thumb = $.fileDisplay()
+            .css("opacity", 0)
+            .fadeTo(1000, 1);
+
+        var uploadedFile = gReviewRequest.createUploadedFile();
+        uploadedFile.setFile(file);
+        uploadedFile.save({
+            buttons: gDraftBannerButtons,
+            success: function(rsp, uploadedFile) {
+                thumb.replaceWith($.fileDisplay(uploadedFile));
+                gDraftBanner.show();
+            },
+            error: function(rsp, msg) {
+                thumb.remove();
+            }
+        });
+    }
 }
 
 $(document).ready(function() {
@@ -1670,6 +1772,36 @@ $(document).ready(function() {
         return false;
     });
 
+    /* UploadedFileComment */
+    $(".file-review").click(function() {
+
+        var self=this;
+        var el=this.el;
+        var data=$('#mycustomid').attr('fileid');
+
+        var comment = gReviewRequest.createReview().createFileComment(data);
+        var comments = [];
+
+        var gCommentDlg = $("#comment-detail")
+                .commentDlg()
+                .css("z-index", 999);
+        gCommentDlg.appendTo("body");
+
+        gCommentDlg
+            .setDraftComment(comment)
+            .positionToSide(gCommentDlg, {
+                side: 'b',
+                fitOnScreen: true
+                });
+        
+        $.event.add(comment, "saved", function() {
+            showReviewBanner();
+            });
+        gCommentDlg
+            .open($('#mycustomid'));
+
+    });
+
     if (gUserAuthenticated) {
         if (window["gEditable"]) {
             $(".editable").reviewRequestFieldEditor();
diff --git a/reviewboard/reviews/evolutions/__init__.py b/reviewboard/reviews/evolutions/__init__.py
index c37002eb39b5028a0cf3be01ea653a553f577326..b3919a2b01b673bfab1c5c2814404dd0e2d290cc 100644
--- a/reviewboard/reviews/evolutions/__init__.py
+++ b/reviewboard/reviews/evolutions/__init__.py
@@ -9,4 +9,5 @@ SEQUENCE = [
     'group_invite_only',
     'group_visible',
     'default_reviewer_local_site',
+    'filemanager',
 ]
diff --git a/reviewboard/reviews/evolutions/filemanager.py b/reviewboard/reviews/evolutions/filemanager.py
new file mode 100644
index 0000000000000000000000000000000000000000..b508fb429faabc1000e6ca9c479e3cadb0f154f6
--- /dev/null
+++ b/reviewboard/reviews/evolutions/filemanager.py
@@ -0,0 +1,12 @@
+from django.db import models
+
+from django_evolution.mutations import AddField
+
+MUTATIONS = [
+    AddField('ReviewRequest', 'files', models.ManyToManyField, related_model='filemanager.UploadedFile'),
+    AddField('ReviewRequest', 'inactive_files', models.ManyToManyField, related_model='filemanager.UploadedFile'),
+    AddField('Review', 'file_comments', models.ManyToManyField, related_model='reviews.UploadedFileComment'),
+    AddField('ReviewRequestDraft', 'files', models.ManyToManyField, related_model='filemanager.UploadedFile'),
+    AddField('ReviewRequestDraft', 'inactive_files', models.ManyToManyField, related_model='filemanager.UploadedFile')
+]
+
diff --git a/reviewboard/reviews/managers.py b/reviewboard/reviews/managers.py
index 69273cfdb8e93217f3fc6c7d1c01a8f76ab21378..e2e165072efc4ceac21778b579ca03f35f1e0821 100644
--- a/reviewboard/reviews/managers.py
+++ b/reviewboard/reviews/managers.py
@@ -333,7 +333,7 @@ class ReviewManager(ConcurrencyManager):
                 if (review_value and not getattr(master_review, attname)):
                     setattr(master_review, attname, review_value)
 
-            for attname in ["comments", "screenshot_comments"]:
+            for attname in ["comments", "screenshot_comments", "file_comments"]:
                 master_m2m = getattr(master_review, attname)
                 review_m2m = getattr(review, attname)
 
diff --git a/reviewboard/reviews/models.py b/reviewboard/reviews/models.py
index 64f97fbec60112ecd3a11849882b8ce48491f14c..c2cf3b1fb710a46058ef7ffc0aa66d324c0e7e3d 100644
--- a/reviewboard/reviews/models.py
+++ b/reviewboard/reviews/models.py
@@ -16,6 +16,7 @@ from djblets.util.templatetags.djblets_images import crop_image, thumbnail
 
 from reviewboard.changedescs.models import ChangeDescription
 from reviewboard.diffviewer.models import DiffSet, DiffSetHistory, FileDiff
+from reviewboard.filemanager.models import UploadedFile
 from reviewboard.reviews.signals import review_request_published, \
                                         reply_published, review_published
 from reviewboard.reviews.errors import PermissionError
@@ -284,6 +285,18 @@ class ReviewRequest(models.Model):
         related_name="inactive_review_request",
         blank=True)
 
+    files = models.ManyToManyField(
+        UploadedFile,
+        related_name="review_request",
+        verbose_name=_("uploaded files"),
+        blank=True)
+    inactive_files = models.ManyToManyField(UploadedFile,
+        verbose_name=_("inactive files"),
+        help_text=_("A list of uploaded files that used to be but are no "
+                    "longer associated with this review request."),
+        related_name="inactive_review_request",
+        blank=True)
+
     changedescs = models.ManyToManyField(ChangeDescription,
         verbose_name=_("change descriptions"),
         related_name="review_request",
@@ -809,6 +822,16 @@ class ReviewRequestDraft(models.Model):
         related_name="inactive_drafts",
         blank=True)
 
+    files = models.ManyToManyField(UploadedFile,
+                                   related_name="drafts",
+                                   verbose_name=_("uploaded files"),
+                                   blank=True)
+    inactive_files = models.ManyToManyField(
+        UploadedFile,
+        verbose_name=_("inactive files"),
+        related_name="inactive_drafts",
+        blank=True)
+
     submitter = property(lambda self: self.review_request.submitter)
 
     # Set this up with a ConcurrencyManager to help prevent race conditions.
@@ -879,6 +902,16 @@ class ReviewRequestDraft(models.Model):
                 screenshot.save()
                 draft.inactive_screenshots.add(screenshot)
 
+            for file in review_request.files.all():
+                file.draft_caption = file.caption
+                file.save()
+                draft.files.add(file)
+
+            for file in review_request.inactive_files.all():
+                file.draft_caption = file.caption
+                file.save()
+                draft.inactive_files.add(file)
+
             draft.save();
 
         return draft
@@ -1078,6 +1111,33 @@ class ReviewRequestDraft(models.Model):
         map(review_request.inactive_screenshots.add,
             self.inactive_screenshots.all())
 
+        # Files are treated like screenshots. The list of files can
+        # change, but so can captions within each file.
+        files = self.files.all()
+        caption_changes = {}
+
+        for f in review_request.files.all():
+            if f in files and f.caption != f.draft_caption:
+                caption_changes[f.id] = {
+                    'old': (f.caption,),
+                    'new': (f.draft_caption,),
+                }
+
+                f.caption = f.draft_caption
+                f.save()
+
+        if caption_changes and self.changedesc:
+            self.changedesc.fields_changed['file_captions'] = \
+                caption_changes
+
+        update_list(review_request.files, self.files,
+                    'files', name_field="caption")
+
+        # There's no change notification required for this field.
+        review_request.inactive_files.clear()
+        map(review_request.inactive_files.add,
+            self.inactive_files.all())
+
         if self.diffset:
             if self.changedesc:
                 if review_request.local_site:
@@ -1275,6 +1335,59 @@ class ScreenshotComment(models.Model):
         ordering = ['timestamp']
 
 
+class UploadedFileComment(models.Model):
+    """A comment on an uploaded file."""
+    file = models.ForeignKey(UploadedFile, verbose_name=_('uploaded_file'),
+                                   related_name="comments")
+    reply_to = models.ForeignKey('self', blank=True, null=True,
+                                 related_name='replies',
+                                 verbose_name=_("reply to"))
+    timestamp = models.DateTimeField(_('timestamp'), default=datetime.now)
+    text = models.TextField(_('comment text'))
+
+    # Set this up with a ConcurrencyManager to help prevent race conditions.
+    objects = ConcurrencyManager()
+
+    def public_replies(self, user=None):
+        """
+        Returns a list of public replies to this comment, optionally
+        specifying the user replying.
+        """
+        if user:
+            return self.replies.filter(Q(review__public=True) |
+                                       Q(review__user=user))
+        else:
+            return self.replies.filter(review__public=True)
+
+    def get_file(self):
+        """
+        Generates the file referenced by this
+        comment and returns the HTML markup embedding it.
+        """
+        return '<a href="%s" alt="%s" />' % \
+            (self.file.file, escape(self.text))
+
+    def get_review_url(self):
+        return "%s#scomment%d" % \
+            (self.review.get().review_request.get_absolute_url(), self.id)
+
+    def save(self, **kwargs):
+        super(UploadedFileComment, self).save()
+
+        try:
+            # Update the review timestamp.
+            review = self.review.get()
+            review.timestamp = datetime.now()
+            review.save()
+        except Review.DoesNotExist:
+            pass
+
+    def __unicode__(self):
+        return self.text
+
+    class Meta:
+        ordering = ['timestamp']
+
 class Review(models.Model):
     """
     A review of a review request.
@@ -1325,6 +1438,11 @@ class Review(models.Model):
         verbose_name=_("screenshot comments"),
         related_name="review",
         blank=True)
+    file_comments = models.ManyToManyField(
+        UploadedFileComment,
+        verbose_name=_("uploaded file comments"),
+        related_name="review",
+        blank=True)
 
     # XXX Deprecated. This will be removed in a future release.
     reviewed_diffset = models.ForeignKey(
@@ -1410,6 +1528,10 @@ class Review(models.Model):
             comment.timetamp = self.timestamp
             comment.save()
 
+        for comment in self.file_comments.all():
+            comment.timetamp = self.timestamp
+            comment.save()
+
         # Update the last_updated timestamp on the review request.
         self.review_request.last_review_timestamp = self.timestamp
         self.review_request.save()
@@ -1437,6 +1559,9 @@ class Review(models.Model):
         for comment in self.screenshot_comments.all():
             comment.delete()
 
+        for comment in self.file_comments.all():
+            comment.delete()
+
         super(Review, self).delete()
 
     def get_absolute_url(self):
diff --git a/reviewboard/reviews/templatetags/reviewtags.py b/reviewboard/reviews/templatetags/reviewtags.py
index 04cbda29c41e9f75c9242d0f0de2d353db7e8a2c..268cee00c46a39aedc83959aa457609ceb7cedf8 100644
--- a/reviewboard/reviews/templatetags/reviewtags.py
+++ b/reviewboard/reviews/templatetags/reviewtags.py
@@ -11,8 +11,9 @@ from djblets.util.templatetags.djblets_utils import humanize_list
 
 from reviewboard.accounts.models import Profile
 from reviewboard.diffviewer.models import DiffSet
+from reviewboard.filemanager.models import UploadedFile
 from reviewboard.reviews.models import Comment, Group, ReviewRequest, \
-                                       ScreenshotComment
+                                       ScreenshotComment, UploadedFileComment
 
 
 register = template.Library()
@@ -212,8 +213,8 @@ def reply_list(context, review, comment, context_type, context_id):
     to display replies to a type of object. In each case, the replies will
     be rendered using the template :template:`reviews/review_reply.html`.
 
-    If ``context_type`` is ``"comment"`` or ``"screenshot_comment"``,
-    the generated list of replies are to ``comment``.
+    If ``context_type`` is ``"comment"``, ``"screenshot_comment"``
+    or ``"file_comment"``, the generated list of replies are to ``comment``.
 
     If ``context_type`` is ``"body_top"`` or ```"body_bottom"``,
     the generated list of replies are to ``review``. Depending on the
@@ -255,7 +256,7 @@ def reply_list(context, review, comment, context_type, context_id):
 
     s = ""
 
-    if context_type == "comment" or context_type == "screenshot_comment":
+    if context_type in ('comment', 'screenshot_comment', 'file_comment'):
         for reply_comment in comment.public_replies(user):
             s += generate_reply_html(reply_comment.review.get(),
                                      reply_comment.timestamp,
@@ -293,6 +294,8 @@ def reply_section(context, review, comment, context_type, context_id):
     if comment != "":
         if type(comment) is ScreenshotComment:
             context_id += 's'
+        elif type(comment) is UploadedFileComment:
+            context_id += 'f'
         context_id += str(comment.id)
 
     return {
diff --git a/reviewboard/reviews/urls.py b/reviewboard/reviews/urls.py
index 911c884b452d6f17fbcf292c0e0e7846e30896e8..62e923023ec78c8faf0e7719d79d8685e8bfaff3 100644
--- a/reviewboard/reviews/urls.py
+++ b/reviewboard/reviews/urls.py
@@ -58,3 +58,4 @@ urlpatterns = patterns('reviewboard.reviews.views',
     # Search
     url(r'^search/$', 'search', name="search"),
 )
+
diff --git a/reviewboard/reviews/views.py b/reviewboard/reviews/views.py
index de098fd0b25cbe1bbf43201fdcbed0eb283f7d10..24219f29ee12ca9ed96d039a29a6c6eaa6296480 100644
--- a/reviewboard/reviews/views.py
+++ b/reviewboard/reviews/views.py
@@ -36,6 +36,7 @@ from reviewboard.diffviewer.diffutils import get_file_chunks_in_range
 from reviewboard.diffviewer.models import DiffSet
 from reviewboard.diffviewer.views import view_diff, view_diff_fragment, \
                                          exception_traceback_string
+from reviewboard.filemanager.forms import UploadFileForm, CommentFileForm
 from reviewboard.reviews.datagrids import DashboardDataGrid, \
                                           GroupDataGrid, \
                                           ReviewRequestDataGrid, \
@@ -68,7 +69,7 @@ def _render_permission_denied(
     return response
 
 
-def _find_review_request(request, review_request_id, local_site_name):
+def find_review_request(request, review_request_id, local_site_name):
     """
     Find a review request based on an ID and optional LocalSite name.
 
@@ -115,6 +116,8 @@ def _make_review_request_context(review_request, extra_context):
         'review_request': review_request,
         'upload_diff_form': upload_diff_form,
         'upload_screenshot_form': UploadScreenshotForm(),
+        'upload_file_form': UploadFileForm(),
+        'comment_file_form': CommentFileForm(),
         'scmtool': scmtool,
     }, **extra_context)
 
@@ -211,6 +214,8 @@ fields_changed_name_map = {
     'target_people': 'Reviewers (People)',
     'screenshots': 'Screenshots',
     'screenshot_captions': 'Screenshot Captions',
+    'files': 'Uploaded Files',
+    'file_captions': 'Uploaded File Captions',
     'diff': 'Diff',
 }
 
@@ -272,7 +277,7 @@ def review_detail(request,
     # local_site configured to have its own review request ID namespace
     # starting from 1.
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -395,6 +400,8 @@ def review_detail(request,
                         info['new'][0] = mark_safe(info['new'][0])
             elif name == "screenshot_captions":
                 change_type = 'screenshot_captions'
+            elif name == "file_captions":
+                change_type = 'file_captions'
             else:
                 # No clue what this is. Bail.
                 continue
@@ -445,7 +452,7 @@ def review_draft_inline_form(request,
                              template_name,
                              local_site_name=None):
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -668,7 +675,7 @@ def diff(request,
     providing the user's current review of the diff if it exists.
     """
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -727,7 +734,7 @@ def raw_diff(request,
     given review request.
     """
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -768,7 +775,7 @@ def comment_diff_fragments(
     # While we don't actually need the review request, we still want to do this
     # lookup in order to get the permissions checking.
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -821,7 +828,7 @@ def diff_fragment(request,
     diff.
     """
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -857,7 +864,7 @@ def preview_review_request_email(
     This is mainly used for debugging.
     """
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -896,7 +903,7 @@ def preview_review_email(request, review_request_id, review_id, format,
     This is mainly used for debugging.
     """
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -949,7 +956,7 @@ def preview_reply_email(request, review_request_id, review_id, reply_id,
     This is mainly used for debugging.
     """
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
@@ -1000,7 +1007,7 @@ def view_screenshot(request,
     Displays a screenshot, along with any comments that were made on it.
     """
     review_request, response = \
-        _find_review_request(request, review_request_id, local_site_name)
+        find_review_request(request, review_request_id, local_site_name)
 
     if not review_request:
         return response
diff --git a/reviewboard/settings.py b/reviewboard/settings.py
index e313e4efb074712102238570f164009cadd2c819..1299ffe72161e7cccebe3bd5ccfbfeed0e2aaa4f 100644
--- a/reviewboard/settings.py
+++ b/reviewboard/settings.py
@@ -107,6 +107,7 @@ INSTALLED_APPS = (
     'reviewboard.admin',
     'reviewboard.changedescs',
     'reviewboard.diffviewer',
+    'reviewboard.filemanager',
     'reviewboard.notifications',
     'reviewboard.reports',
     'reviewboard.reviews',
diff --git a/reviewboard/templates/reviews/review_request_actions_secondary.html b/reviewboard/templates/reviews/review_request_actions_secondary.html
index 7d0a095c6512a30416c25c7935af909319925976..20605f92e0ea955b23d608e435a6fec654b0159e 100644
--- a/reviewboard/templates/reviews/review_request_actions_secondary.html
+++ b/reviewboard/templates/reviews/review_request_actions_secondary.html
@@ -18,6 +18,7 @@
 {% endifuserorperm %}
 {% ifuserorperm review_request.submitter "reviews.can_edit_reviewrequest" %}
  <li><a id="upload-screenshot-link" href="#">{% trans "Add Screenshot" %}</a></li>
+ <li><a id="upload-file-link" href="#">{% trans "Add File" %}</a></li>
 {% if upload_diff_form %}
  <li><a id="upload-diff-link" href="#">{% if review_request_details.diffset or review_request.diffset_history.diffsets.count %}{% trans "Update Diff" %}{% else %}{% trans "Upload Diff" %}{% endif %}</a></li>
 {% endif %}
diff --git a/reviewboard/templates/reviews/review_request_box.html b/reviewboard/templates/reviews/review_request_box.html
index a18d7a824ae02f733c663b3f5aae4f0aab8b6f6f..8427ef457c6607cd5b1379f32f723432ba5c8dbd 100644
--- a/reviewboard/templates/reviews/review_request_box.html
+++ b/reviewboard/templates/reviews/review_request_box.html
@@ -88,3 +88,31 @@
    <br clear="both" />
   </div>
  </div>
+<div class="content clearfix"{% ifequal review_request_details.files.count 0 %} style="display: none;"{% endifequal %}>
+<label for="file-list">{% trans "Uploaded Files" %}:</label>
+ <div id="file-list">
+  {% for file in review_request_details.files.all %}
+   <div class="file-container">
+   <dd>
+    <label for="uploaded_file_{{file.id}}_caption">{{file.get_title}}
+     <a href="#" id="mycustomid" fileid="{{file.id}}" class="file-review" >{% trans "Review File" %}</a>
+     <a href="{{file.get_path}}">{% trans "Download File" %}</a>
+     {% ifuserorperm review_request.submitter "reviews.delete_file" %}
+      <a href="#" class="delete">
+       <img src="{{MEDIA_URL}}rb/images/delete.png?{{MEDIA_SERIAL}}" alt="{% trans "Delete File" %}" />
+      </a>
+     {% endifuserorperm %}
+    </label>
+    <pre id="uploaded_file_{{file.id}}_caption" class="editable file-editable">
+     {% if draft %}{{file.draft_caption}}{% else %}{{file.caption}}{% endif %}
+    </pre>
+   </dd>
+   </div>
+   {% endfor %}
+  <br clear="both" />
+ </div>
+</div>
+</div>
+
+{% include "reviews/comments_dlg.html" %}
+
diff --git a/reviewboard/templates/reviews/review_request_dlgs.html b/reviewboard/templates/reviews/review_request_dlgs.html
index 29325b3a4116958527ba90c544181411ee9bbd66..28b0b6426c79c79e2e9cb5c7ca87054c8f8ee78d 100644
--- a/reviewboard/templates/reviews/review_request_dlgs.html
+++ b/reviewboard/templates/reviews/review_request_dlgs.html
@@ -40,6 +40,27 @@
 
       return false;
     });
+
+    $("#upload-file-link").click(function() {
+      $("<div/>").formDlg({
+        title: "{% trans "Upload File" %}",
+        confirmLabel: "{% trans "Upload" %}",
+        dataStoreObject: gReviewRequest.createUploadedFile(),
+        width: "50em",
+        upload: true,
+        fields: {% form_dialog_fields upload_file_form %},
+        success: function(rsp) {
+            if (!$("#file-list").length == 0) {
+                $.fileDisplay(rsp.uploaded_file);
+            }
+
+            gDraftBanner.show();
+        }
+      });
+
+      return false;
+    });
+
   });
 </script>
 {% endifuserorperm %}
diff --git a/reviewboard/webapi/resources.py b/reviewboard/webapi/resources.py
index d0d2a137362767c8b4ada072ebf552c7a6882e45..7c82e5dcb50d47b34a3bf72a3a1850f98906aeb0 100644
--- a/reviewboard/webapi/resources.py
+++ b/reviewboard/webapi/resources.py
@@ -1,6 +1,7 @@
 import logging
 import re
 import urllib
+import pdb
 
 import dateutil.parser
 from django.conf import settings
@@ -35,12 +36,15 @@ from reviewboard import get_version_string, get_package_version, is_release
 from reviewboard.accounts.models import Profile
 from reviewboard.diffviewer.diffutils import get_diff_files
 from reviewboard.diffviewer.forms import EmptyDiffError
+from reviewboard.filemanager.forms import UploadFileForm
+from reviewboard.filemanager.models import UploadedFile
 from reviewboard.reviews.errors import PermissionError
 from reviewboard.reviews.forms import UploadDiffForm, UploadScreenshotForm
 from reviewboard.reviews.models import Comment, DiffSet, FileDiff, Group, \
                                        Repository, ReviewRequest, \
                                        ReviewRequestDraft, Review, \
-                                       ScreenshotComment, Screenshot
+                                       ScreenshotComment, Screenshot, \
+                                       UploadedFileComment
 from reviewboard.scmtools import sshutils
 from reviewboard.scmtools.errors import AuthenticationError, \
                                         BadHostKeyError, \
@@ -1321,6 +1325,7 @@ class BaseWatchedObjectResource(WebAPIResource):
         except User.DoesNotExist:
             return DOES_NOT_EXIST
 
+    @webapi_check_local_site
     @webapi_login_required
     @webapi_response_errors(DOES_NOT_EXIST, NOT_LOGGED_IN, PERMISSION_DENIED)
     @webapi_request_fields(required={
@@ -1351,6 +1356,7 @@ class BaseWatchedObjectResource(WebAPIResource):
             self.item_result_key: obj,
         }
 
+    @webapi_check_local_site
     @webapi_login_required
     def delete(self, request, watched_obj_id, *args, **kwargs):
         try:
@@ -2348,6 +2354,7 @@ class BaseScreenshotResource(WebAPIResource):
     def serialize_thumbnail_url_field(self, obj):
         return obj.get_thumbnail_url()
 
+    @webapi_check_local_site
     @webapi_login_required
     @webapi_response_errors(DOES_NOT_EXIST, NOT_LOGGED_IN, PERMISSION_DENIED,
                             INVALID_FORM_DATA)
@@ -2411,6 +2418,7 @@ class BaseScreenshotResource(WebAPIResource):
             self.item_result_key: screenshot,
         }
 
+    @webapi_check_local_site
     @webapi_login_required
     @webapi_request_fields(
         optional={
@@ -2451,6 +2459,7 @@ class BaseScreenshotResource(WebAPIResource):
             self.item_result_key: screenshot,
         }
 
+    @webapi_check_local_site
     @webapi_login_required
     @webapi_response_errors(DOES_NOT_EXIST, NOT_LOGGED_IN, PERMISSION_DENIED)
     def delete(self, request, *args, **kwargs):
@@ -2572,6 +2581,248 @@ class DraftScreenshotResource(BaseScreenshotResource):
 draft_screenshot_resource = DraftScreenshotResource()
 
 
+class BaseUploadedFileResource(WebAPIResource):
+    """A base resource representing uploaded files."""
+    model = UploadedFile
+    name = 'uploaded_file'
+    fields = {
+        'id': {
+            'type': int,
+            'description': 'The numeric ID of the file.',
+        },
+        'caption': {
+            'type': str,
+            'description': "The file's descriptive caption.",
+        },
+        'title': {
+            'type': str,
+            'description': "The path of the file, relative to the media "
+                           "directory configured on the Review Board server.",
+        },
+        'url': {
+            'type': str,
+            'description': "The URL of the file, for downloading purposes."
+                           "an absolute URL (for example, if it is just a "
+                           "path), then it's relative to the Review Board "
+                           "server's URL.",
+        },
+        'file_url': {
+            'type': str,
+            'description': "The URL of the file object.",
+        },
+    }
+
+    uri_object_key = 'file_id'
+
+    def get_queryset(self, request, review_request_id, *args, **kwargs):
+        return self.model.objects.filter(review_request=review_request_id)
+
+    def serialize_title_field(self, obj):
+        return obj.get_title()
+
+    def serialize_url_field(self, obj):
+        return obj.get_path()
+
+    def serialize_file_url_field(self, obj):
+        return obj.get_absolute_url()
+
+    @webapi_check_local_site
+    @webapi_login_required
+    @webapi_response_errors(DOES_NOT_EXIST, PERMISSION_DENIED,
+                            INVALID_FORM_DATA, NOT_LOGGED_IN)
+    @webapi_request_fields(
+        required={
+            'path': {
+                'type': file,
+                'description': 'The file to upload.',
+            },
+        },
+        optional={
+            'caption': {
+                'type': str,
+                'description': 'The optional caption describing the '
+                               'file.',
+            },
+        },
+    )
+    def create(self, request, *args, **kwargs):
+        """Creates a new file from an uploaded file.
+
+        This accepts any standard file format (including images) and associates
+        it with a draft of a review request.
+
+        It is expected that the client will send the data as part of a
+        :mimetype:`multipart/form-data` mimetype. The file's name
+        and content should be stored in the ``path`` field. A typical request
+        may look like::
+
+            -- SoMe BoUnDaRy
+            Content-Disposition: form-data; name=path; filename="foo.txt"
+
+            <PNG content here>
+            -- SoMe BoUnDaRy --
+        """
+        try:
+            review_request = \
+                review_request_resource.get_object(request, *args, **kwargs)
+        except ObjectDoesNotExist:
+            return DOES_NOT_EXIST
+
+        if not review_request.is_mutable_by(request.user):
+            return _no_access_error(request.user)
+
+        form_data = request.POST.copy()
+        form = UploadFileForm(form_data, request.FILES)
+
+        if not form.is_valid():
+            return WebAPIResponseFormError(request, form)
+
+        try:
+            file = form.create(request.FILES['path'], review_request)
+        except ValueError, e:
+            return INVALID_FORM_DATA, {
+                'fields': {
+                    'path': [str(e)],
+                },
+            }
+
+        return 201, {
+            self.item_result_key: file,
+        }
+
+    @webapi_check_local_site
+    @webapi_login_required
+    @webapi_response_errors(DOES_NOT_EXIST, NOT_LOGGED_IN, PERMISSION_DENIED)
+    @webapi_request_fields(
+        optional={
+            'caption': {
+                'type': str,
+                'description': 'The new caption for the file.',
+            },
+        }
+    )
+    def update(self, request, caption=None, *args, **kwargs):
+        """Updates the file's data.
+
+        This allows updating the file in a draft. The caption, currently,
+        is the only thing that can be updated.
+        """
+        try:
+            review_request = \
+                review_request_resource.get_object(request, *args, **kwargs)
+            file = uploaded_file_resource.get_object(request, *args,
+                                                        **kwargs)
+        except ObjectDoesNotExist:
+            return DOES_NOT_EXIST
+
+        if not review_request.is_mutable_by(request.user):
+            return PERMISSION_DENIED
+
+        try:
+            review_request_draft_resource.prepare_draft(request,
+                                                        review_request)
+        except PermissionDenied:
+            return _no_access_error(request.user)
+
+        file.draft_caption = caption
+        file.save()
+
+        return 200, {
+            self.item_result_key: screenshot,
+        }
+
+
+class DraftUploadedFileResource(BaseUploadedFileResource):
+    """Provides information on new uploaded files being added to a draft of
+    a review request.
+
+    These are files that will be shown once the pending review request
+    draft is published.
+    """
+    name = 'draft_files'
+    uri_name = 'files'
+    model_parent_key = 'drafts'
+    allowed_methods = ('GET', 'DELETE', 'POST', 'PUT',)
+
+    def get_queryset(self, request, review_request_id, *args, **kwargs):
+        try:
+            draft = review_request_draft_resource.get_object(
+                request, review_request_id, *args, **kwargs)
+
+            inactive_ids = \
+                draft.inactive_files.values_list('pk', flat=True)
+
+            q = Q(review_request=review_request_id) | Q(drafts=draft)
+            query = self.model.objects.filter(q)
+            query = query.exclude(pk__in=inactive_ids)
+            return query
+        except ObjectDoesNotExist:
+            return self.model.objects.none()
+
+    def serialize_caption_field(self, obj):
+        return obj.draft_caption or obj.caption
+
+    @webapi_check_local_site
+    @webapi_login_required
+    @augment_method_from(WebAPIResource)
+    def get(self, *args, **kwargs):
+        pass
+
+    @webapi_check_local_site
+    @webapi_login_required
+    @augment_method_from(WebAPIResource)
+    def delete(self, *args, **kwargs):
+        """Deletes the file from the draft.
+
+        This will remove the screenshot from the draft review request.
+        This cannot be undone.
+
+        This can be used to remove old files that were previously
+        shown, as well as newly added files that were part of the
+        draft.
+
+        Instead of a payload response on success, this will return :http:`204`.
+        """
+        pass
+
+    @webapi_check_local_site
+    @webapi_login_required
+    @augment_method_from(WebAPIResource)
+    def get_list(self, *args, **kwargs):
+        """Returns a list of draft files.
+
+        Each screenshot in this list is an uploaded screenshot that will
+        be shown in the final review request. These may include newly
+        uploaded files or files that were already part of the
+        existing review request. In the latter case, existing files
+        are shown so that their captions can be added.
+        """
+        pass
+
+    def _get_list_impl(self, request, *args, **kwargs):
+        """Returns the list of files on this draft.
+
+        This is a specialized version of the standard get_list function
+        that uses this resource to serialize the children, in order to
+        guarantee that we'll be able to identify them as files that are
+        part of the draft.
+        """
+        return WebAPIResponsePaginated(
+            request,
+            queryset=self.get_queryset(request, is_list=True,
+                                       *args, **kwargs),
+            results_key=self.list_result_key,
+            serialize_object_func=
+                lambda obj: self.serialize_object(obj, request=request,
+                                                  *args, **kwargs),
+            extra_data={
+                'links': self.get_links(self.list_child_resources,
+                                        request=request, *args, **kwargs),
+            })
+
+draft_uploaded_file_resource = DraftUploadedFileResource()
+
+
 class ReviewRequestDraftResource(WebAPIResource):
     """An editable draft of a review request.
 
@@ -2659,6 +2910,7 @@ class ReviewRequestDraftResource(WebAPIResource):
 
     item_child_resources = [
         draft_screenshot_resource,
+        draft_uploaded_file_resource
     ]
 
     @classmethod
@@ -3315,6 +3567,7 @@ class ReviewReplyScreenshotCommentResource(BaseScreenshotCommentResource):
         q = q.filter(review=reply_id, review__base_reply_to=review_id)
         return q
 
+    @webapi_check_local_site
     @webapi_login_required
     @webapi_response_errors(DOES_NOT_EXIST, INVALID_FORM_DATA,
                             NOT_LOGGED_IN, PERMISSION_DENIED)
@@ -3375,6 +3628,7 @@ class ReviewReplyScreenshotCommentResource(BaseScreenshotCommentResource):
             self.item_result_key: new_comment,
         }
 
+    @webapi_check_local_site
     @webapi_login_required
     @webapi_response_errors(DOES_NOT_EXIST, NOT_LOGGED_IN, PERMISSION_DENIED)
     @webapi_request_fields(
@@ -3415,7 +3669,7 @@ class ReviewReplyScreenshotCommentResource(BaseScreenshotCommentResource):
 
     @augment_method_from(BaseScreenshotCommentResource)
     def delete(self, *args, **kwargs):
-        """Deletes a screnshot comment from a draft reply.
+        """Deletes a screenshot comment from a draft reply.
 
         This will remove the comment from the reply. This cannot be undone.
 
@@ -3449,6 +3703,399 @@ review_reply_screenshot_comment_resource = \
     ReviewReplyScreenshotCommentResource()
 
 
+class BaseFileCommentResource(WebAPIResource):
+    """A base resource for file comments."""
+    model = UploadedFileComment
+    name = 'file_comment'
+    fields = {
+        'id': {
+            'type': int,
+            'description': 'The numeric ID of the comment.',
+        },
+        'file': {
+            'type': 'reviewboard.webapi.resources.UploadedFileResource',
+            'description': 'The file the comment was made on.',
+        },
+        'text': {
+            'type': str,
+            'description': 'The comment text.',
+        },
+        'timestamp': {
+            'type': str,
+            'description': 'The date and time that the comment was made '
+                           '(in YYYY-MM-DD HH:MM:SS format).',
+        },
+        'public': {
+            'type': bool,
+            'description': 'Whether or not the comment is part of a public '
+                           'review.',
+        },
+        'user': {
+            'type': 'reviewboard.webapi.resources.UserResource',
+            'description': 'The user who made the comment.',
+        },
+    }
+
+    uri_object_key = 'comment_id'
+    allowed_methods = ('GET',)
+
+    def get_queryset(self, request, *args, **kwargs):
+        review_request = \
+            review_request_resource.get_object(request, *args, **kwargs)
+        return self.model.objects.filter(
+            file__review_request=review_request_id,
+            review__isnull=False)
+
+    def serialize_public_field(self, obj):
+        return obj.review.get().public
+
+    def serialize_timesince_field(self, obj):
+        return timesince(obj.timestamp)
+
+    def serialize_user_field(self, obj):
+        return obj.review.get().user
+
+    @webapi_check_local_site
+    @augment_method_from(WebAPIResource)
+    def get(self, *args, **kwargs):
+        """Returns information on the comment.
+
+        This contains the comment text, time the comment was made,
+        and the location of the comment region on the screenshot, amongst
+        other information. It can be used to reconstruct the exact
+        position of the comment for use as an overlay on the screenshot.
+        """
+        pass
+
+    def get_href(self, obj, request, *args, **kwargs):
+        """Returns the URL for this object"""
+        base = review_resource.get_href(
+            obj.review.all()[0], request, *args, **kwargs)
+        return '%s%s/%s/' % (base, self.uri_name, obj.id)
+
+
+class FileCommentResource(BaseFileCommentResource):
+    """Provides information on filess comments made on a review request.
+
+    The list of comments cannot be modified from this resource. It's meant
+    purely as a way to see existing comments that were made on a diff. These
+    comments will span all public reviews.
+    """
+    model_parent_key = 'uploaded_file'
+    uri_object_key = None
+
+    def get_queryset(self, request, review_request_id, file_id,
+                     *args, **kwargs):
+        q = super(FileCommentResource, self).get_queryset(
+            request, review_request_id, *args, **kwargs)
+        q = q.filter(file=file_id)
+        return q
+
+    @webapi_check_local_site
+    @augment_method_from(BaseFileCommentResource)
+    def get_list(self, *args, **kwargs):
+        """Returns the list of screenshot comments on a file.
+
+        This list of comments will cover all comments made on this
+        file from all reviews.
+        """
+        pass
+
+file_comment_resource = FileCommentResource()
+
+
+class ReviewFileCommentResource(BaseFileCommentResource):
+    """Provides information on file comments made on a review.
+
+    If the review is a draft, then comments can be added, deleted, or
+    changed on this list. However, if the review is already published,
+    then no changes can be made.
+    """
+    allowed_methods = ('GET', 'POST', 'PUT', 'DELETE')
+    model_parent_key = 'review'
+
+    def get_queryset(self, request, review_request_id, review_id,
+                     *args, **kwargs):
+        q = super(ReviewFileCommentResource, self).get_queryset(
+            request, review_request_id, *args, **kwargs)
+        return q.filter(review=review_id)
+
+    def has_delete_permissions(self, request, comment, *args, **kwargs):
+        review = comment.review.get()
+        return not review.public and review.user == request.user
+
+    @webapi_check_local_site
+    @webapi_login_required
+    @webapi_response_errors(DOES_NOT_EXIST, INVALID_FORM_DATA,  
+                            PERMISSION_DENIED, NOT_LOGGED_IN)
+    @webapi_request_fields(
+        required = {
+            'file_id': {
+                'type': int,
+                'description': 'The ID of the file being commented on.',
+            },
+            'text': {
+                'type': str,
+                'description': 'The comment text.',
+            },
+        },
+    )
+    def create(self, request, file_id=None, text=None, *args, **kwargs):
+        """Creates a file comment on a review.
+
+        This will create a new comment on a file as part of a review.
+        The comment contains text and dimensions for the area being commented
+        on.
+        """
+        try:
+            review_request = \
+                review_request_resource.get_object(request, *args, **kwargs)
+            review = review_resource.get_object(request, *args, **kwargs)
+        except ObjectDoesNotExist:
+            return DOES_NOT_EXIST
+        
+        #pdb.set_trace()
+
+        if not review_resource.has_modify_permissions(request, review):
+            return _no_access_error(request.user)
+
+        try:
+            file = UploadedFile.objects.get(pk=file_id,
+                                              review_request=review_request)
+        except ObjectDoesNotExist:
+            return INVALID_FORM_DATA, {
+                'fields': {
+                    'file_id': ['This is not a valid file ID'],
+                }
+            }
+
+        print "text=%s" % text
+
+        new_comment = self.model(file=file, text=text)
+        new_comment.save()
+        print "comment=%s" % new_comment
+
+        print type(new_comment)
+
+        review.file_comments.add(new_comment)
+        review.save()
+
+        return 201, {
+            self.item_result_key: new_comment,
+        }
+
+    @webapi_check_local_site
+    @webapi_login_required
+    @webapi_response_errors(DOES_NOT_EXIST, NOT_LOGGED_IN, PERMISSION_DENIED)
+    @webapi_request_fields(
+        optional = {
+            'text': {
+                'type': str,
+                'description': 'The comment text.',
+            },
+        },
+    )
+    def update(self, request, *args, **kwargs):
+        """Updates a file comment.
+
+        This can update the text or region of an existing comment. It
+        can only be done for comments that are part of a draft review.
+        """
+        try:
+            review_request_resource.get_object(request, *args, **kwargs)
+            review = review_resource.get_object(request, *args, **kwargs)
+            file_comment = self.get_object(request, *args, **kwargs)
+        except ObjectDoesNotExist:
+            return DOES_NOT_EXIST
+
+        if not review_resource.has_modify_permissions(request, review):
+            return _no_access_error(request.user)
+
+        for field in ('text'):
+            value = kwargs.get(field, None)
+
+            if value is not None:
+                setattr(file_comment, field, value)
+
+        file_comment.save()
+
+        return 200, {
+            self.item_result_key: file_comment,
+        }
+
+    @augment_method_from(BaseFileCommentResource)
+    def delete(self, *args, **kwargs):
+        """Deletes the comment.
+
+        This will remove the comment from the review. This cannot be undone.
+
+        Only comments on draft reviews can be deleted. Attempting to delete
+        a published comment will return a Permission Denied error.
+
+        Instead of a payload response on success, this will return :http:`204`.
+        """
+        pass
+
+    @augment_method_from(BaseFileCommentResource)
+    def get_list(self, *args, **kwargs):
+        """Returns the list of file comments made on a review."""
+        pass
+
+review_file_comment_resource = ReviewFileCommentResource()
+
+
+class ReviewReplyFileCommentResource(BaseFileCommentResource):
+    """Provides information on replies to file comments made on a
+    review reply.
+
+    If the reply is a draft, then comments can be added, deleted, or
+    changed on this list. However, if the reply is already published,
+    then no changed can be made.
+    """
+    allowed_methods = ('GET', 'POST', 'PUT', 'DELETE')
+    model_parent_key = 'review'
+    fields = dict({
+        'reply_to': {
+            'type': ReviewFileCommentResource,
+            'description': 'The comment being replied to.',
+        },
+    }, **BaseFileCommentResource.fields)
+
+    def get_queryset(self, request, review_request_id, review_id, reply_id,
+                     *args, **kwargs):
+        q = super(ReviewReplyFileCommentResource, self).get_queryset(
+            request, review_request_id, *args, **kwargs)
+        q = q.filter(review=reply_id, review__base_reply_to=review_id)
+        return q
+
+    @webapi_check_local_site
+    @webapi_login_required
+    @webapi_response_errors(DOES_NOT_EXIST, INVALID_FORM_DATA,
+                            NOT_LOGGED_IN, PERMISSION_DENIED)
+    @webapi_request_fields(
+        required = {
+            'reply_to_id': {
+                'type': int,
+                'description': 'The ID of the comment being replied to.',
+            },
+            'text': {
+                'type': str,
+                'description': 'The comment text.',
+            },
+        },
+    )
+    def create(self, request, reply_to_id, text, *args, **kwargs):
+        """Creates a reply to a file comment on a review.
+
+        This will create a reply to a file comment on a review.
+        The new comment will contain the same dimensions of the comment
+        being replied to, but may contain new text.
+        """
+        try:
+            review_request_resource.get_object(request, *args, **kwargs)
+            reply = review_reply_resource.get_object(request, *args, **kwargs)
+        except ObjectDoesNotExist:
+            return DOES_NOT_EXIST
+
+        if not review_reply_resource.has_modify_permissions(request, reply):
+            return _no_access_error(request.user)
+
+        try:
+            comment = review_file_comment_resource.get_object(
+                request,
+                comment_id=reply_to_id,
+                *args, **kwargs)
+        except ObjectDoesNotExist:
+            return INVALID_FORM_DATA, {
+                'fields': {
+                    'reply_to_id': ['This is not a valid file '
+                                    'comment ID'],
+                }
+            }
+
+        new_comment = self.model(file=comment.file,
+                                 text=text)
+        new_comment.save()
+
+        reply.file_comments.add(new_comment)
+        reply.save()
+
+        return 201, {
+            self.item_result_key: new_comment,
+        }
+
+    @webapi_check_local_site
+    @webapi_login_required
+    @webapi_response_errors(DOES_NOT_EXIST, NOT_LOGGED_IN, PERMISSION_DENIED)
+    @webapi_request_fields(
+        required = {
+            'text': {
+                'type': str,
+                'description': 'The new comment text.',
+            },
+        },
+    )
+    def update(self, request, *args, **kwargs):
+        """Updates a reply to a file comment.
+
+        This can only update the text in the comment. The comment being
+        replied to cannot change.
+        """
+        try:
+            review_request_resource.get_object(request, *args, **kwargs)
+            reply = review_reply_resource.get_object(request, *args, **kwargs)
+            file_comment = self.get_object(request, *args, **kwargs)
+        except ObjectDoesNotExist:
+            return DOES_NOT_EXIST
+
+        if not review_reply_resource.has_modify_permissions(request, reply):
+            return _no_access_error(request.user)
+
+        for field in ('text',):
+            value = kwargs.get(field, None)
+
+            if value is not None:
+                setattr(file_comment, field, value)
+
+        file_comment.save()
+
+        return 200, {
+            self.item_result_key: file_comment,
+        }
+
+    @augment_method_from(BaseFileCommentResource)
+    def delete(self, *args, **kwargs):
+        """Deletes a file comment from a draft reply.
+
+        This will remove the comment from the reply. This cannot be undone.
+
+        Only comments on draft replies can be deleted. Attempting to delete
+        a published comment will return a Permission Denied error.
+
+        Instead of a payload response, this will return :http:`204`.
+        """
+        pass
+
+    @augment_method_from(BaseFileCommentResource)
+    def get(self, *args, **kwargs):
+        """Returns information on a reply to a file comment.
+
+        Much of the information will be identical to that of the comment
+        being replied to.
+        """
+        pass
+
+    @augment_method_from(BaseFileCommentResource)
+    def get_list(self, *args, **kwargs):
+        """Returns the list of replies to file comments made on a
+        review reply.
+        """
+        pass
+
+review_reply_file_comment_resource = \
+    ReviewReplyFileCommentResource()
+
+
 class BaseReviewResource(WebAPIResource):
     """Base class for review resources.
 
@@ -3697,6 +4344,7 @@ class ReviewReplyDraftResource(WebAPIResource):
     singleton = True
     uri_name = 'draft'
 
+    @webapi_check_local_site
     @webapi_login_required
     def get(self, request, *args, **kwargs):
         """Returns the location of the current draft reply.
@@ -3769,6 +4417,7 @@ class ReviewReplyResource(BaseReviewResource):
     item_child_resources = [
         review_reply_diff_comment_resource,
         review_reply_screenshot_comment_resource,
+        review_reply_file_comment_resource,
     ]
 
     list_child_resources = [
@@ -3853,6 +4502,7 @@ class ReviewReplyResource(BaseReviewResource):
                 'Location': self.get_href(reply, request, *args, **kwargs),
             }
 
+    @webapi_check_local_site
     @webapi_login_required
     @webapi_response_errors(DOES_NOT_EXIST, NOT_LOGGED_IN, PERMISSION_DENIED)
     @webapi_request_fields(
@@ -3958,6 +4608,7 @@ class ReviewDraftResource(WebAPIResource):
     singleton = True
     uri_name = 'draft'
 
+    @webapi_check_local_site
     @webapi_login_required
     def get(self, request, *args, **kwargs):
         try:
@@ -3987,6 +4638,7 @@ class ReviewResource(BaseReviewResource):
         review_diff_comment_resource,
         review_reply_resource,
         review_screenshot_comment_resource,
+        review_file_comment_resource,
     ]
 
     list_child_resources = [
@@ -4086,6 +4738,87 @@ class ScreenshotResource(BaseScreenshotResource):
 screenshot_resource = ScreenshotResource()
 
 
+class UploadedFileResource(BaseUploadedFileResource):
+    """A resource representing a screenshot on a review request."""
+    model_parent_key = 'review_request'
+
+    item_child_resources = [
+        file_comment_resource,
+    ]
+
+    allowed_methods = ('GET', 'POST', 'PUT', 'DELETE')
+
+    @augment_method_from(BaseUploadedFileResource)
+    def get_list(self, *args, **kwargs):
+        """Returns a list of screenshots on the review request.
+
+        Each screenshot in this list is an uploaded screenshot that is
+        shown on the review request.
+        """
+        pass
+
+    @augment_method_from(BaseUploadedFileResource)
+    def create(self, request, *args, **kwargs):
+        """Creates a new screenshot from an uploaded file.
+
+        This accepts any standard image format (PNG, GIF, JPEG) and associates
+        it with a draft of a review request.
+
+        Creating a new screenshot will automatically create a new review
+        request draft, if one doesn't already exist. This screenshot will
+        be part of that draft, and will be shown on the review request
+        when it's next published.
+
+        It is expected that the client will send the data as part of a
+        :mimetype:`multipart/form-data` mimetype. The screenshot's name
+        and content should be stored in the ``path`` field. A typical request
+        may look like::
+
+            -- SoMe BoUnDaRy
+            Content-Disposition: form-data; name=path; filename="foo.png"
+
+            <PNG content here>
+            -- SoMe BoUnDaRy --
+        """
+        pass
+
+    @augment_method_from(BaseUploadedFileResource)
+    def update(self, request, caption=None, *args, **kwargs):
+        """Updates the screenshot's data.
+
+        This allows updating the screenshot. The caption, currently,
+        is the only thing that can be updated.
+
+        Updating a screenshot will automatically create a new review request
+        draft, if one doesn't already exist. The updates won't be public
+        until the review request draft is published.
+        """
+        pass
+
+    @webapi_check_local_site
+    @webapi_login_required
+    @augment_method_from(WebAPIResource)
+    def delete(self, *args, **kwargs):
+        """Deletes the screenshot.
+
+        This will remove the screenshot from the draft review request.
+        This cannot be undone.
+
+        Deleting a screenshot will automatically create a new review request
+        draft, if one doesn't already exist. The screenshot won't be actually
+        removed until the review request draft is published.
+
+        This can be used to remove old screenshots that were previously
+        shown, as well as newly added screenshots that were part of the
+        draft.
+
+        Instead of a payload response on success, this will return :http:`204`.
+        """
+        pass
+
+uploaded_file_resource = UploadedFileResource()
+
+
 class ReviewRequestLastUpdateResource(WebAPIResource):
     """Provides information on the last update made to a review request.
 
@@ -4272,6 +5005,7 @@ class ReviewRequestResource(WebAPIResource):
         review_request_last_update_resource,
         review_resource,
         screenshot_resource,
+        uploaded_file_resource
     ]
 
     allowed_methods = ('GET', 'POST', 'PUT', 'DELETE')
@@ -4955,9 +5689,15 @@ register_resource_for_model(
 register_resource_for_model(ReviewRequest, review_request_resource)
 register_resource_for_model(ReviewRequestDraft, review_request_draft_resource)
 register_resource_for_model(Screenshot, screenshot_resource)
+register_resource_for_model(UploadedFile, uploaded_file_resource)
 register_resource_for_model(
     ScreenshotComment,
     lambda obj: obj.review.get().is_reply() and
                 review_reply_screenshot_comment_resource or
                 review_screenshot_comment_resource)
+register_resource_for_model(
+    UploadedFileComment,
+    lambda obj: obj.review.get().is_reply() and
+                review_reply_file_comment_resource or
+                review_file_comment_resource)
 register_resource_for_model(User, user_resource)
