| // Copyright 2016 The Go Authors. All rights reserved. | |
| // Use of this source code is governed by a BSD-style | |
| // license that can be found in the LICENSE file. | |
| package app | |
| import ( | |
| "context" | |
| "encoding/json" | |
| "errors" | |
| "fmt" | |
| "io" | |
| "mime/multipart" | |
| "net/http" | |
| "net/url" | |
| "path/filepath" | |
| "sort" | |
| "strings" | |
| "time" | |
| "golang.org/x/build/perfdata/db" | |
| "golang.org/x/perf/storage/benchfmt" | |
| ) | |
| // upload is the handler for the /upload endpoint. It serves a form on | |
| // GET requests and processes files in a multipart/x-form-data POST | |
| // request. | |
| func (a *App) upload(w http.ResponseWriter, r *http.Request) { | |
| ctx := requestContext(r) | |
| user, err := a.Auth(w, r) | |
| switch { | |
| case err == ErrResponseWritten: | |
| return | |
| case err != nil: | |
| errorf(ctx, "%v", err) | |
| http.Error(w, err.Error(), 500) | |
| return | |
| } | |
| if r.Method == http.MethodGet { | |
| http.ServeFile(w, r, filepath.Join(a.BaseDir, "static/upload.html")) | |
| return | |
| } | |
| if r.Method != http.MethodPost { | |
| http.Error(w, "/upload must be called as a POST request", http.StatusMethodNotAllowed) | |
| return | |
| } | |
| // We use r.MultipartReader instead of r.ParseForm to avoid | |
| // storing uploaded data in memory. | |
| mr, err := r.MultipartReader() | |
| if err != nil { | |
| errorf(ctx, "%v", err) | |
| http.Error(w, err.Error(), 500) | |
| return | |
| } | |
| result, err := a.processUpload(ctx, user, mr) | |
| if err != nil { | |
| errorf(ctx, "%v", err) | |
| http.Error(w, err.Error(), 500) | |
| return | |
| } | |
| w.Header().Set("Content-Type", "application/json") | |
| if err := json.NewEncoder(w).Encode(result); err != nil { | |
| errorf(ctx, "%v", err) | |
| http.Error(w, err.Error(), 500) | |
| return | |
| } | |
| } | |
| // uploadStatus is the response to an /upload POST served as JSON. | |
| type uploadStatus struct { | |
| // UploadID is the upload ID assigned to the upload. | |
| UploadID string `json:"uploadid"` | |
| // FileIDs is the list of file IDs assigned to the files in the upload. | |
| FileIDs []string `json:"fileids"` | |
| // ViewURL is a URL that can be used to interactively view the upload. | |
| ViewURL string `json:"viewurl,omitempty"` | |
| } | |
| // processUpload takes one or more files from a multipart.Reader, | |
| // writes them to the filesystem, and indexes their content. | |
| func (a *App) processUpload(ctx context.Context, user string, mr *multipart.Reader) (*uploadStatus, error) { | |
| var upload *db.Upload | |
| var fileids []string | |
| uploadtime := time.Now().UTC().Format(time.RFC3339) | |
| for i := 0; ; i++ { | |
| p, err := mr.NextPart() | |
| if err == io.EOF { | |
| break | |
| } | |
| if err != nil { | |
| return nil, err | |
| } | |
| name := p.FormName() | |
| if name == "commit" { | |
| continue | |
| } | |
| if name != "file" { | |
| return nil, fmt.Errorf("unexpected field %q", name) | |
| } | |
| if upload == nil { | |
| var err error | |
| upload, err = a.DB.NewUpload(ctx) | |
| if err != nil { | |
| return nil, err | |
| } | |
| defer func() { | |
| if upload != nil { | |
| upload.Abort() | |
| } | |
| }() | |
| } | |
| // The incoming file needs to be stored in Cloud | |
| // Storage and it also needs to be indexed. If the file | |
| // is invalid (contains no valid records) it needs to | |
| // be rejected and the Cloud Storage upload aborted. | |
| meta := map[string]string{ | |
| "upload": upload.ID, | |
| "upload-part": fmt.Sprintf("%s/%d", upload.ID, i), | |
| "upload-time": uploadtime, | |
| } | |
| name = p.FileName() | |
| if slash := strings.LastIndexAny(name, `/\`); slash >= 0 { | |
| name = name[slash+1:] | |
| } | |
| if name != "" { | |
| meta["upload-file"] = name | |
| } | |
| if user != "" { | |
| meta["by"] = user | |
| } | |
| // We need to do two things with the incoming data: | |
| // - Write it to permanent storage via a.FS | |
| // - Write index records to a.DB | |
| // AND if anything fails, attempt to clean up both the | |
| // FS and the index records. | |
| if err := a.indexFile(ctx, upload, p, meta); err != nil { | |
| return nil, err | |
| } | |
| fileids = append(fileids, meta["upload-part"]) | |
| } | |
| if upload == nil { | |
| return nil, errors.New("no files processed") | |
| } | |
| if err := upload.Commit(); err != nil { | |
| return nil, err | |
| } | |
| status := &uploadStatus{UploadID: upload.ID, FileIDs: fileids} | |
| if a.ViewURLBase != "" { | |
| status.ViewURL = a.ViewURLBase + url.QueryEscape(upload.ID) | |
| } | |
| upload = nil | |
| return status, nil | |
| } | |
| func (a *App) indexFile(ctx context.Context, upload *db.Upload, p io.Reader, meta map[string]string) (err error) { | |
| path := fmt.Sprintf("uploads/%s.txt", meta["upload-part"]) | |
| fw, err := a.FS.NewWriter(ctx, path, meta) | |
| if err != nil { | |
| return err | |
| } | |
| defer func() { | |
| start := time.Now() | |
| if err != nil { | |
| fw.CloseWithError(err) | |
| } else { | |
| err = fw.Close() | |
| } | |
| infof(ctx, "Close(%q) took %.2f seconds", path, time.Since(start).Seconds()) | |
| }() | |
| var keys []string | |
| for k := range meta { | |
| keys = append(keys, k) | |
| } | |
| sort.Strings(keys) | |
| for _, k := range keys { | |
| if _, err := fmt.Fprintf(fw, "%s: %s\n", k, meta[k]); err != nil { | |
| return err | |
| } | |
| } | |
| // Write a blank line to separate metadata from user-generated content. | |
| fmt.Fprintf(fw, "\n") | |
| // TODO(quentin): Add a separate goroutine and buffer for writes to fw? | |
| tr := io.TeeReader(p, fw) | |
| br := benchfmt.NewReader(tr) | |
| br.AddLabels(meta) | |
| i := 0 | |
| for br.Next() { | |
| i++ | |
| if err := upload.InsertRecord(br.Result()); err != nil { | |
| return err | |
| } | |
| } | |
| if err := br.Err(); err != nil { | |
| return err | |
| } | |
| if i == 0 { | |
| return errors.New("no valid benchmark lines found") | |
| } | |
| return nil | |
| } |