Merge "pkg/server: add files "zipper" to DownloadHandler"

This commit is contained in:
Mathieu Lonjaret 2017-03-17 13:36:40 +00:00 committed by Gerrit Code Review
commit 75424def01
8 changed files with 392 additions and 70 deletions

View File

@ -940,7 +940,7 @@ func (pr *publishRequest) serveFileDownload(des *search.DescribedBlob) {
Cache: pr.ph.cache, Cache: pr.ph.cache,
ForceMIME: mimeType, ForceMIME: mimeType,
} }
dh.ServeHTTP(pr.rw, pr.req, fileref) dh.ServeFile(pr.rw, pr.req, fileref)
} }
// Given a described blob, optionally follows a camliContent and // Given a described blob, optionally follows a camliContent and

16
make.go
View File

@ -457,8 +457,12 @@ func genPublisherJS(gopherjsBin string) error {
// modtime of the existing gopherjs.js if there was no reason to. // modtime of the existing gopherjs.js if there was no reason to.
output := filepath.Join(buildSrcDir, filepath.FromSlash(publisherJS)) output := filepath.Join(buildSrcDir, filepath.FromSlash(publisherJS))
tmpOutput := output + ".new" tmpOutput := output + ".new"
// TODO(mpl): maybe not with -m when building for devcam. args := []string{"build", "--tags", "nocgo"}
args := []string{"build", "--tags", "nocgo", "-m", "-o", tmpOutput, "camlistore.org/app/publisher/js"} if *embedResources {
// when embedding for "production", use -m to minify the javascript output
args = append(args, "-m")
}
args = append(args, "-o", tmpOutput, "camlistore.org/app/publisher/js")
cmd := exec.Command(gopherjsBin, args...) cmd := exec.Command(gopherjsBin, args...)
cmd.Env = append(cleanGoEnv(), cmd.Env = append(cleanGoEnv(),
"GOPATH="+buildGoPath, "GOPATH="+buildGoPath,
@ -530,8 +534,12 @@ func genWebUIJS(gopherjsBin string) error {
// modtime of the existing goui.js if there was no reason to. // modtime of the existing goui.js if there was no reason to.
output := filepath.Join(buildSrcDir, filepath.FromSlash(gopherjsUI)) output := filepath.Join(buildSrcDir, filepath.FromSlash(gopherjsUI))
tmpOutput := output + ".new" tmpOutput := output + ".new"
// TODO(mpl): maybe not with -m when building for devcam. args := []string{"build", "--tags", "nocgo"}
args := []string{"build", "--tags", "nocgo", "-m", "-o", tmpOutput, "camlistore.org/server/camlistored/ui/goui"} if *embedResources {
// when embedding for "production", use -m to minify the javascript output
args = append(args, "-m")
}
args = append(args, "-o", tmpOutput, "camlistore.org/server/camlistored/ui/goui")
cmd := exec.Command(gopherjsBin, args...) cmd := exec.Command(gopherjsBin, args...)
cmd.Env = append(cleanGoEnv(), cmd.Env = append(cleanGoEnv(),
"GOPATH="+buildGoPath, "GOPATH="+buildGoPath,

View File

@ -17,16 +17,19 @@ limitations under the License.
package server package server
import ( import (
"archive/zip"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"os" "os"
"regexp"
"strings" "strings"
"time" "time"
"camlistore.org/pkg/blob" "camlistore.org/pkg/blob"
"camlistore.org/pkg/blobserver" "camlistore.org/pkg/blobserver"
"camlistore.org/pkg/httputil"
"camlistore.org/pkg/magic" "camlistore.org/pkg/magic"
"camlistore.org/pkg/schema" "camlistore.org/pkg/schema"
"camlistore.org/pkg/search" "camlistore.org/pkg/search"
@ -34,9 +37,20 @@ import (
"golang.org/x/net/context" "golang.org/x/net/context"
) )
const oneYear = 365 * 86400 * time.Second const (
oneYear = 365 * 86400 * time.Second
downloadTimeLayout = "20060102150405"
)
var debugPack = strings.Contains(os.Getenv("CAMLI_DEBUG_X"), "packserve") var (
debugPack = strings.Contains(os.Getenv("CAMLI_DEBUG_X"), "packserve")
// Download URL suffix:
// $1: blobref (checked in download handler)
// $2: TODO. optional "/filename" to be sent as recommended download name,
// if sane looking
downloadPattern = regexp.MustCompile(`^download/([^/]+)(/.*)?$`)
)
type DownloadHandler struct { type DownloadHandler struct {
Fetcher blob.Fetcher Fetcher blob.Fetcher
@ -55,19 +69,20 @@ func (dh *DownloadHandler) blobSource() blob.Fetcher {
} }
type fileInfo struct { type fileInfo struct {
mime string mime string
name string name string
size int64 size int64
rs io.ReadSeeker modtime time.Time
close func() error // release the rs rs io.ReadSeeker
whyNot string // for testing, why fileInfoPacked failed. close func() error // release the rs
whyNot string // for testing, why fileInfoPacked failed.
} }
func (dh *DownloadHandler) fileInfo(req *http.Request, file blob.Ref) (fi fileInfo, packed bool, err error) { func (dh *DownloadHandler) fileInfo(r *http.Request, file blob.Ref) (fi fileInfo, packed bool, err error) {
ctx := context.TODO() ctx := context.TODO()
// Fast path for blobpacked. // Fast path for blobpacked.
fi, ok := fileInfoPacked(ctx, dh.Search, dh.Fetcher, req, file) fi, ok := fileInfoPacked(ctx, dh.Search, dh.Fetcher, r, file)
if debugPack { if debugPack {
log.Printf("download.go: fileInfoPacked: ok=%v, %+v", ok, fi) log.Printf("download.go: fileInfoPacked: ok=%v, %+v", ok, fi)
} }
@ -86,16 +101,17 @@ func (dh *DownloadHandler) fileInfo(req *http.Request, file blob.Ref) (fi fileIn
mime = "application/octet-stream" mime = "application/octet-stream"
} }
return fileInfo{ return fileInfo{
mime: mime, mime: mime,
name: fr.FileName(), name: fr.FileName(),
size: fr.Size(), size: fr.Size(),
rs: fr, modtime: fr.ModTime(),
close: fr.Close, rs: fr,
close: fr.Close,
}, false, nil }, false, nil
} }
// Fast path for blobpacked. // Fast path for blobpacked.
func fileInfoPacked(ctx context.Context, sh *search.Handler, src blob.Fetcher, req *http.Request, file blob.Ref) (packFileInfo fileInfo, ok bool) { func fileInfoPacked(ctx context.Context, sh *search.Handler, src blob.Fetcher, r *http.Request, file blob.Ref) (packFileInfo fileInfo, ok bool) {
if sh == nil { if sh == nil {
return fileInfo{whyNot: "no search"}, false return fileInfo{whyNot: "no search"}, false
} }
@ -103,7 +119,7 @@ func fileInfoPacked(ctx context.Context, sh *search.Handler, src blob.Fetcher, r
if !ok { if !ok {
return fileInfo{whyNot: "fetcher type"}, false return fileInfo{whyNot: "fetcher type"}, false
} }
if req != nil && req.Header.Get("Range") != "" { if r != nil && r.Header.Get("Range") != "" {
// TODO: not handled yet. Maybe not even important, // TODO: not handled yet. Maybe not even important,
// considering rarity. // considering rarity.
return fileInfo{whyNot: "range header"}, false return fileInfo{whyNot: "range header"}, false
@ -135,34 +151,70 @@ func fileInfoPacked(ctx context.Context, sh *search.Handler, src blob.Fetcher, r
log.Printf("ui: fileInfoPacked: skipping fast path due to error from WholeRefFetcher (%T): %v", src, err) log.Printf("ui: fileInfoPacked: skipping fast path due to error from WholeRefFetcher (%T): %v", src, err)
return fileInfo{whyNot: "WholeRefFetcher error"}, false return fileInfo{whyNot: "WholeRefFetcher error"}, false
} }
modtime := fi.ModTime
if modtime.IsAnyZero() {
modtime = fi.Time
}
return fileInfo{ return fileInfo{
mime: fi.MIMEType, mime: fi.MIMEType,
name: fi.FileName, name: fi.FileName,
size: fi.Size, size: fi.Size,
rs: readerutil.NewFakeSeeker(rc, fi.Size-offset), modtime: modtime.Time(),
close: rc.Close, rs: readerutil.NewFakeSeeker(rc, fi.Size-offset),
close: rc.Close,
}, true }, true
} }
func (dh *DownloadHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request, file blob.Ref) { // ServeHTTP answers the following queries:
if req.Method != "GET" && req.Method != "HEAD" { //
http.Error(rw, "Invalid download method", http.StatusBadRequest) // POST:
return // ?files=sha1-foo,sha1-bar,sha1-baz
} // Creates a zip archive of the provided files and serves it in the response.
if req.Header.Get("If-Modified-Since") != "" { //
// Immutable, so any copy's a good copy. // GET:
rw.WriteHeader(http.StatusNotModified) // /<file-schema-blobref>
// Serves the file described by the requested file schema blobref.
func (dh *DownloadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
dh.serveZip(w, r)
return return
} }
fi, packed, err := dh.fileInfo(req, file) suffix := httputil.PathSuffix(r)
m := downloadPattern.FindStringSubmatch(suffix)
if m == nil {
httputil.ErrorRouting(w, r)
return
}
file, ok := blob.Parse(m[1])
if !ok {
http.Error(w, "Invalid blobref", http.StatusBadRequest)
return
}
// TODO(mpl): make use of m[2] (the optional filename).
dh.ServeFile(w, r, file)
}
func (dh *DownloadHandler) ServeFile(w http.ResponseWriter, r *http.Request, file blob.Ref) {
if r.Method != "GET" && r.Method != "HEAD" {
http.Error(w, "Invalid download method", http.StatusBadRequest)
return
}
if r.Header.Get("If-Modified-Since") != "" {
// Immutable, so any copy's a good copy.
w.WriteHeader(http.StatusNotModified)
return
}
fi, packed, err := dh.fileInfo(r, file)
if err != nil { if err != nil {
http.Error(rw, "Can't serve file: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Can't serve file: "+err.Error(), http.StatusInternalServerError)
return return
} }
defer fi.close() defer fi.close()
h := rw.Header() h := w.Header()
h.Set("Content-Length", fmt.Sprint(fi.size)) h.Set("Content-Length", fmt.Sprint(fi.size))
h.Set("Expires", time.Now().Add(oneYear).Format(http.TimeFormat)) h.Set("Expires", time.Now().Add(oneYear).Format(http.TimeFormat))
h.Set("Content-Type", fi.mime) h.Set("Content-Type", fi.mime)
@ -179,11 +231,11 @@ func (dh *DownloadHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request,
if fileName == "" { if fileName == "" {
fileName = "file-" + file.String() + ".dat" fileName = "file-" + file.String() + ".dat"
} }
rw.Header().Set("Content-Disposition", "attachment; filename="+fileName) w.Header().Set("Content-Disposition", "attachment; filename="+fileName)
} }
if req.Method == "HEAD" && req.FormValue("verifycontents") != "" { if r.Method == "HEAD" && r.FormValue("verifycontents") != "" {
vbr, ok := blob.Parse(req.FormValue("verifycontents")) vbr, ok := blob.Parse(r.FormValue("verifycontents"))
if !ok { if !ok {
return return
} }
@ -193,10 +245,119 @@ func (dh *DownloadHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request,
} }
io.Copy(hash, fi.rs) // ignore errors, caught later io.Copy(hash, fi.rs) // ignore errors, caught later
if vbr.HashMatches(hash) { if vbr.HashMatches(hash) {
rw.Header().Set("X-Camli-Contents", vbr.String()) w.Header().Set("X-Camli-Contents", vbr.String())
} }
return return
} }
http.ServeContent(rw, req, "", time.Now(), fi.rs) http.ServeContent(w, r, "", time.Now(), fi.rs)
}
// statFiles stats the given refs and returns an error if any one of them is not
// found.
// It is the responsibility of the caller to check that dh.blobSource() is a
// blobserver.BlobStatter.
func (dh *DownloadHandler) statFiles(refs []blob.Ref) error {
statter, _ := dh.blobSource().(blobserver.BlobStatter)
statted := make(map[blob.Ref]bool)
ch := make(chan (blob.SizedRef))
errc := make(chan (error))
go func() {
err := statter.StatBlobs(ch, refs)
close(ch)
errc <- err
}()
for sbr := range ch {
statted[sbr.Ref] = true
}
if err := <-errc; err != nil {
log.Printf("Error statting blob files for download archive: %v", err)
return fmt.Errorf("error looking for files")
}
for _, v := range refs {
if _, ok := statted[v]; !ok {
return fmt.Errorf("%q was not found", v)
}
}
return nil
}
// serveZip creates a zip archive from the files provided as
// ?files=sha1-foo,sha1-bar,... and serves it as the response.
func (dh *DownloadHandler) serveZip(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "Invalid download method", http.StatusBadRequest)
return
}
filesValue := r.FormValue("files")
if filesValue == "" {
http.Error(w, "No files blobRefs specified", http.StatusBadRequest)
return
}
files := strings.Split(filesValue, ",")
var refs []blob.Ref
for _, file := range files {
br, ok := blob.Parse(file)
if !ok {
http.Error(w, fmt.Sprintf("%q is not a valid blobRef", file), http.StatusBadRequest)
return
}
refs = append(refs, br)
}
// We check as many things as we can before writing the zip, because
// once we start sending a response we can't http.Error anymore.
// TODO(mpl): instead of just statting, read the files (from the
// blobSource, which should be Cache then Fetcher), and write them to the
// Cache.
_, ok := dh.blobSource().(blobserver.BlobStatter)
if ok {
if err := dh.statFiles(refs); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// TODO(mpl): do not zip if only one file is requested?
h := w.Header()
h.Set("Content-Type", "application/zip")
zipName := "camli-download-" + time.Now().Format(downloadTimeLayout) + ".zip"
h.Set("Content-Disposition", "attachment; filename="+zipName)
zw := zip.NewWriter(w)
zipFile := func(br blob.Ref) error {
fi, _, err := dh.fileInfo(r, br)
if err != nil {
return err
}
defer fi.close()
zh := &zip.FileHeader{
Name: fi.name,
Method: zip.Store,
}
zh.SetModTime(fi.modtime.UTC())
zfh, err := zw.CreateHeader(zh)
if err != nil {
return err
}
_, err = io.Copy(zfh, fi.rs)
if err != nil {
return err
}
return nil
}
for _, br := range refs {
if err := zipFile(br); err != nil {
log.Printf("error zipping %v: %v", br, err)
// http.Error is of no use since we've already started sending a response
panic(http.ErrAbortHandler)
}
}
if err := zw.Close(); err != nil {
log.Printf("error closing zip stream: %v", err)
panic(http.ErrAbortHandler)
}
} }

View File

@ -240,7 +240,7 @@ func handleGetViaSharing(rw http.ResponseWriter, req *http.Request,
Fetcher: fetcher, Fetcher: fetcher,
// TODO(aa): It would be nice to specify a local cache here, as the UI handler does. // TODO(aa): It would be nice to specify a local cache here, as the UI handler does.
} }
dh.ServeHTTP(rw, req, blobRef) dh.ServeFile(rw, req, blobRef)
} else { } else {
gethandler.ServeBlobRef(rw, req, blobRef, fetcher) gethandler.ServeBlobRef(rw, req, blobRef, fetcher)
} }

View File

@ -53,15 +53,8 @@ import (
) )
var ( var (
staticFilePattern = regexp.MustCompile(`^([a-zA-Z0-9\-\_\.]+\.(html|js|css|png|jpg|gif|svg))$`) staticFilePattern = regexp.MustCompile(`^([a-zA-Z0-9\-\_\.]+\.(html|js|css|png|jpg|gif|svg))$`)
identOrDotPattern = regexp.MustCompile(`^[a-zA-Z\_]+(\.[a-zA-Z\_]+)*$`) identOrDotPattern = regexp.MustCompile(`^[a-zA-Z\_]+(\.[a-zA-Z\_]+)*$`)
// Download URL suffix:
// $1: blobref (checked in download handler)
// $2: optional "/filename" to be sent as recommended download name,
// if sane looking
downloadPattern = regexp.MustCompile(`^download/([^/]+)(/.*)?$`)
thumbnailPattern = regexp.MustCompile(`^thumbnail/([^/]+)(/.*)?$`) thumbnailPattern = regexp.MustCompile(`^thumbnail/([^/]+)(/.*)?$`)
treePattern = regexp.MustCompile(`^tree/([^/]+)(/.*)?$`) treePattern = regexp.MustCompile(`^tree/([^/]+)(/.*)?$`)
closurePattern = regexp.MustCompile(`^closure/(([^/]+)(/.*)?)$`) closurePattern = regexp.MustCompile(`^closure/(([^/]+)(/.*)?)$`)
@ -504,22 +497,9 @@ func (ui *UIHandler) discovery() *camtypes.UIDiscovery {
return uiDisco return uiDisco
} }
func (ui *UIHandler) serveDownload(rw http.ResponseWriter, req *http.Request) { func (ui *UIHandler) serveDownload(w http.ResponseWriter, r *http.Request) {
if ui.root.Storage == nil { if ui.root.Storage == nil {
http.Error(rw, "No BlobRoot configured", 500) http.Error(w, "No BlobRoot configured", 500)
return
}
suffix := httputil.PathSuffix(req)
m := downloadPattern.FindStringSubmatch(suffix)
if m == nil {
httputil.ErrorRouting(rw, req)
return
}
fbr, ok := blob.Parse(m[1])
if !ok {
http.Error(rw, "Invalid blobref", 400)
return return
} }
@ -528,7 +508,7 @@ func (ui *UIHandler) serveDownload(rw http.ResponseWriter, req *http.Request) {
Search: ui.search, Search: ui.search,
Cache: ui.Cache, Cache: ui.Cache,
} }
dh.ServeHTTP(rw, req, fbr) dh.ServeHTTP(w, r)
} }
func (ui *UIHandler) serveThumbnail(rw http.ResponseWriter, req *http.Request) { func (ui *UIHandler) serveThumbnail(rw http.ResponseWriter, req *http.Request) {

View File

@ -0,0 +1,149 @@
/*
Copyright 2017 The Camlistore Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Package downloadbutton provides a Button element that is used in the sidebar of
// the web UI, to download as a zip file all selected files.
package downloadbutton
import (
"fmt"
"strings"
"camlistore.org/pkg/blob"
"github.com/myitcv/gopherjs/react"
"honnef.co/go/js/dom"
)
// New returns the button element. It should be used as the entry point, to
// create the needed React element.
//
// key is the id for when the button is in a list, see
// https://facebook.github.io/react/docs/lists-and-keys.html
//
// config is the web UI config that was fetched from the server.
//
// getSelection returns the list of files (blobRefs) selected for downloading.
func New(key string, config map[string]string, getSelection func() []string) react.Element {
if config == nil {
fmt.Println("Nil config for DownloadItemsBtn")
return nil
}
downloadHelper, ok := config["downloadHelper"]
if !ok {
fmt.Println("No downloadHelper in config for DownloadItemsBtn")
return nil
}
if getSelection == nil {
fmt.Println("Nil getSelection for DownloadItemsBtn")
return nil
}
if key == "" {
// A key is only needed in the context of a list, which is why
// it is up to the caller to choose it. Just creating it here for
// the sake of consistency.
key = "downloadItemsButton"
}
props := DownloadItemsBtnProps{
Key: key,
GetSelection: getSelection,
Config: config,
downloadHelper: downloadHelper,
}
return DownloadItemsBtn(props).Render()
}
// DownloadItemsBtnDef is the definition for the button, that Renders as a React
// Button.
type DownloadItemsBtnDef struct {
react.ComponentDef
}
type DownloadItemsBtnProps struct {
// Key is the id for when the button is in a list, see
// https://facebook.github.io/react/docs/lists-and-keys.html
Key string
// GetSelection returns the list of files (blobRefs) selected
// for downloading.
GetSelection func() []string
// Config is the web UI config that was fetched from the server.
Config map[string]string
downloadHelper string
}
func (p *DownloadItemsBtnDef) Props() DownloadItemsBtnProps {
uprops := p.ComponentDef.Props()
return uprops.(DownloadItemsBtnProps)
}
func DownloadItemsBtn(p DownloadItemsBtnProps) *DownloadItemsBtnDef {
res := &DownloadItemsBtnDef{}
react.BlessElement(res, p)
return res
}
func (d *DownloadItemsBtnDef) Render() react.Element {
return react.Button(
react.ButtonProps(func(bp *react.ButtonPropsDef) {
bp.OnClick = d.handleDownloadSelection
bp.Key = d.Props().Key
}),
react.S("Download"),
)
}
func (d *DownloadItemsBtnDef) handleDownloadSelection(*react.SyntheticMouseEvent) {
// Note: there's a "memleak", as in: until the selection is cleared and
// another one is started, this button stays allocated. It is of no
// consequence in this case as we don't allocate a lot for this element (in
// previous experiments where the zip archive was in memory, the leak was
// definitely noticeable then), but it is something to keep in mind for
// future elements.
go func() {
if err := d.downloadSelection(); err != nil {
dom.GetWindow().Alert(fmt.Sprintf("%v", err))
}
}()
}
func (d *DownloadItemsBtnDef) downloadSelection() error {
selection := d.Props().GetSelection()
downloadPrefix := d.Props().downloadHelper
fileRefs := []string{}
for _, file := range selection {
ref, ok := blob.Parse(file)
if !ok {
return fmt.Errorf("Cannot download %q, not a valid blobRef\n", file)
}
fileRefs = append(fileRefs, ref.String())
}
el := dom.GetWindow().Document().CreateElement("input")
input := el.(*dom.HTMLInputElement)
input.Type = "text"
input.Name = "files"
input.Value = strings.Join(fileRefs, ",")
el = dom.GetWindow().Document().CreateElement("form")
form := el.(*dom.HTMLFormElement)
form.Action = downloadPrefix
form.Method = "POST"
form.AppendChild(input)
form.Submit()
return nil
}

View File

@ -20,12 +20,14 @@ package main
import ( import (
"camlistore.org/server/camlistored/ui/goui/aboutdialog" "camlistore.org/server/camlistored/ui/goui/aboutdialog"
"camlistore.org/server/camlistored/ui/goui/downloadbutton"
"github.com/gopherjs/gopherjs/js" "github.com/gopherjs/gopherjs/js"
) )
func main() { func main() {
js.Global.Set("goreact", map[string]interface{}{ js.Global.Set("goreact", map[string]interface{}{
"AboutMenuItem": aboutdialog.New, "AboutMenuItem": aboutdialog.New,
"DownloadItemsBtn": downloadbutton.New,
}) })
} }

View File

@ -1056,6 +1056,27 @@ cam.IndexPage = React.createClass({
); );
}, },
getDownloadSelectionItem_: function() {
return goreact.DownloadItemsBtn('donwloadBtnSidebar',
this.props.config,
// TODO(mpl): I'm doing the selection business in javascript for now,
// since we already have the search session results handy.
// It shouldn't be any problem to move it to Go later.
function() {
var selection = goog.object.getKeys(this.state.selection);
var files = [];
selection.forEach(function(br) {
var meta = this.childSearchSession_.getResolvedMeta(br);
if (!meta || !meta.file || !meta.file.fileName) {
// TODO(mpl): only do direct files for now. maybe recurse later.
return;
}
files.push(meta.blobRef);
}.bind(this))
return files;
}.bind(this));
},
getSidebar_: function(selectedAspect) { getSidebar_: function(selectedAspect) {
if (selectedAspect) { if (selectedAspect) {
if (selectedAspect.fragment == 'search' || selectedAspect.fragment == 'contents') { if (selectedAspect.fragment == 'search' || selectedAspect.fragment == 'contents') {
@ -1082,6 +1103,7 @@ cam.IndexPage = React.createClass({
this.getRemoveSelectionFromSetItem_(), this.getRemoveSelectionFromSetItem_(),
this.getDeleteSelectionItem_(), this.getDeleteSelectionItem_(),
this.getViewOriginalSelectionItem_(), this.getViewOriginalSelectionItem_(),
this.getDownloadSelectionItem_(),
].filter(goog.functions.identity), ].filter(goog.functions.identity),
selectedItems: this.state.selection selectedItems: this.state.selection
}); });