/****************************************************************************** * * Description : * * Handler of large file uploads/downloads. Validates request first then calls * a handler. * *****************************************************************************/ package main import ( "encoding/base64" "encoding/json" "errors" "io" "math/rand" "mime" "net/http" "net/url" "strconv" "strings" "time" "github.com/tinode/chat/pbx" "github.com/tinode/chat/server/auth" "github.com/tinode/chat/server/logs" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" "google.golang.org/grpc/peer" ) // Allowed mime types for user-provided Content-type field. Must be alphabetically sorted. // Types not in the list are converted to "application/octet-stream". // See https://www.iana.org/assignments/media-types/media-types.xhtml var allowedMimeTypes = []string{"application/", "audio/", "font/", "image/", "text/", "video/"} func largeFileServeHTTP(wrt http.ResponseWriter, req *http.Request) { now := types.TimeNow() enc := json.NewEncoder(wrt) mh := store.Store.GetMediaHandler() statsInc("FileDownloadsTotal", 1) writeHttpResponse := func(msg *ServerComMessage, err error) { // Gorilla CompressHandler requires Content-Type to be set. wrt.Header().Set("Content-Type", "application/json; charset=utf-8") wrt.WriteHeader(msg.Ctrl.Code) enc.Encode(msg) if err != nil { logs.Warn.Println("media serve:", req.URL.String(), err) } } // Preflight request: process before any security checks. if req.Method == http.MethodOptions { headers, statusCode, err := mh.Headers(types.ZeroTenantID, req.Method, req.URL, req.Header, true) if err != nil { writeHttpResponse(decodeStoreError(err, "", now, nil), err) return } for name, values := range headers { for _, value := range values { wrt.Header().Add(name, value) } } if statusCode <= 0 { statusCode = http.StatusNoContent } wrt.WriteHeader(statusCode) logs.Info.Println("media serve: preflight completed") return } // Check if this is a GET/HEAD request. if req.Method != http.MethodGet && req.Method != http.MethodHead { writeHttpResponse(ErrOperationNotAllowed("", "", now), errors.New("method '"+req.Method+"' not allowed")) return } // Check for API key presence if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid { writeHttpResponse(ErrAPIKeyRequired(now), errors.New("invalid or missing API key")) return } // Check authorization: either auth information or SID must be present authMethod, secret := getHttpAuth(req) tenantID, uid, challenge, err := authFileRequest(authMethod, secret, req.FormValue("sid"), getRemoteAddr(req)) if err != nil { writeHttpResponse(decodeStoreError(err, "", now, nil), err) return } if challenge != nil { writeHttpResponse(InfoChallenge("", now, challenge), nil) return } if uid.IsZero() { // Not authenticated writeHttpResponse(ErrAuthRequired("", "", now, now), errors.New("user not authenticated")) return } // Check if media handler redirects or adds headers. headers, statusCode, err := mh.Headers(tenantID, req.Method, req.URL, req.Header, true) if err != nil { writeHttpResponse(decodeStoreError(err, "", now, nil), err) return } for name, values := range headers { for _, value := range values { wrt.Header().Add(name, value) } } if statusCode != 0 { // The handler requested to terminate further processing. wrt.WriteHeader(statusCode) if req.Method == http.MethodGet { enc.Encode(&ServerComMessage{ Ctrl: &MsgServerCtrl{ Code: statusCode, Text: http.StatusText(statusCode), Timestamp: now, }, }) } logs.Info.Println("media serve: completed with status", statusCode, "uid=", uid) return } if req.Method == http.MethodHead { wrt.WriteHeader(http.StatusOK) logs.Info.Println("media serve: completed", req.Method, "uid=", uid) return } fd, rsc, err := mh.Download(tenantID, req.URL.String()) if err != nil { writeHttpResponse(decodeStoreError(err, "", now, nil), err) return } defer rsc.Close() wrt.Header().Set("Content-Type", fd.MimeType) asAttachment, _ := strconv.ParseBool(req.URL.Query().Get("asatt")) // Force download for html files as a security measure. asAttachment = asAttachment || strings.Contains(fd.MimeType, "html") || strings.Contains(fd.MimeType, "xml") || strings.HasPrefix(fd.MimeType, "application/") || // The 'message', 'model', and 'multipart' cannot currently appear, but checked anyway in case // DetectContentType changes its logic. strings.HasPrefix(fd.MimeType, "message/") || strings.HasPrefix(fd.MimeType, "model/") || strings.HasPrefix(fd.MimeType, "multipart/") || strings.HasPrefix(fd.MimeType, "text/") if asAttachment { wrt.Header().Set("Content-Disposition", "attachment") } http.ServeContent(wrt, req, "", fd.UpdatedAt, rsc) logs.Info.Println("media serve: OK, uid=", uid) } // largeFileReceiveHTTP receives files from client over HTTP(S) and passes them to the configured media handler. func largeFileReceiveHTTP(wrt http.ResponseWriter, req *http.Request) { now := types.TimeNow() enc := json.NewEncoder(wrt) mh := store.Store.GetMediaHandler() statsInc("FileUploadsTotal", 1) writeHttpResponse := func(msg *ServerComMessage, err error) { // Gorilla CompressHandler requires Content-Type to be set. wrt.Header().Set("Content-Type", "application/json; charset=utf-8") wrt.WriteHeader(msg.Ctrl.Code) enc.Encode(msg) if err != nil { logs.Info.Println("media upload:", msg.Ctrl.Code, msg.Ctrl.Text, "/", err) } } // Preflight request: process before any security checks. if req.Method == http.MethodOptions { headers, statusCode, err := mh.Headers(types.ZeroTenantID, req.Method, req.URL, req.Header, true) if err != nil { writeHttpResponse(decodeStoreError(err, "", now, nil), err) return } for name, values := range headers { for _, value := range values { wrt.Header().Add(name, value) } } if statusCode <= 0 { statusCode = http.StatusNoContent } wrt.WriteHeader(statusCode) logs.Info.Println("media upload: preflight completed") return } // Check if this is a POST/PUT/HEAD request. if req.Method != http.MethodPost && req.Method != http.MethodPut && req.Method != http.MethodHead { writeHttpResponse(ErrOperationNotAllowed("", "", now), errors.New("method '"+req.Method+"' not allowed")) return } if globals.maxFileUploadSize > 0 { // Enforce maximum upload size. req.Body = http.MaxBytesReader(wrt, req.Body, globals.maxFileUploadSize) } // Check for API key presence if isValid, _ := checkAPIKey(getAPIKey(req)); !isValid { writeHttpResponse(ErrAPIKeyRequired(now), nil) return } msgID := req.FormValue("id") // Check authorization: either auth information or SID must be present authMethod, secret := getHttpAuth(req) tenantID, uid, challenge, err := authFileRequest(authMethod, secret, req.FormValue("sid"), getRemoteAddr(req)) if err != nil { writeHttpResponse(decodeStoreError(err, msgID, now, nil), err) return } if challenge != nil { writeHttpResponse(InfoChallenge(msgID, now, challenge), nil) return } if uid.IsZero() && req.FormValue("topic") != "newacc" { // Not authenticated and not signup. writeHttpResponse(ErrAuthRequired(msgID, "", now, now), nil) return } // Check if uploads are handled elsewhere. headers, statusCode, err := mh.Headers(tenantID, req.Method, req.URL, req.Header, true) if err != nil { logs.Info.Println("media upload: headers check failed", err) writeHttpResponse(decodeStoreError(err, "", now, nil), err) return } for name, values := range headers { for _, value := range values { wrt.Header().Add(name, value) } } if statusCode != 0 { // The handler requested to terminate further processing. wrt.WriteHeader(statusCode) if req.Method == http.MethodPost || req.Method == http.MethodPut { enc.Encode(&ServerComMessage{ Ctrl: &MsgServerCtrl{ Code: statusCode, Text: http.StatusText(statusCode), Timestamp: now, }, }) } logs.Info.Println("media upload: completed with status", statusCode) return } if req.Method == http.MethodHead || req.Method == http.MethodOptions { wrt.WriteHeader(http.StatusOK) logs.Info.Println("media upload: completed", req.Method) return } file, header, err := req.FormFile("file") if err != nil { logs.Info.Println("media upload: invalid multipart form", err) if strings.Contains(err.Error(), "request body too large") { writeHttpResponse(ErrTooLarge(msgID, "", now), err) } else { writeHttpResponse(ErrMalformed(msgID, "", now), err) } return } buff := make([]byte, 512) if _, err = file.Read(buff); err != nil { writeHttpResponse(ErrUnknown(msgID, "", now), err) return } mimeType := http.DetectContentType(buff) // If DetectContentType fails, see if client-provided content type can be used. if mimeType == "application/octet-stream" { if userContentType, params, err := mime.ParseMediaType(header.Header.Get("Content-Type")); err == nil { // Make sure the content-type is legit. for _, allowed := range allowedMimeTypes { if strings.HasPrefix(userContentType, allowed) { if userContentType = mime.FormatMediaType(userContentType, params); userContentType != "" { mimeType = userContentType } break } } } } fdef := &types.FileDef{ ObjHeader: types.ObjHeader{ Id: store.Store.GetUidString(), }, TenantID: tenantID, User: uid.String(), MimeType: mimeType, } fdef.InitTimes() if _, err = file.Seek(0, io.SeekStart); err != nil { writeHttpResponse(ErrUnknown(msgID, "", now), err) return } url, size, err := mh.Upload(fdef, file) if err != nil { logs.Info.Println("media upload: failed", file, "key", fdef.Location, err) store.Files.FinishUpload(tenantID, fdef, false, 0) writeHttpResponse(decodeStoreError(err, msgID, now, nil), err) return } fdef, err = store.Files.FinishUpload(tenantID, fdef, true, size) if err != nil { logs.Info.Println("media upload: failed to finalize", file, "key", fdef.Location, err) // Best effort cleanup. mh.Delete([]string{fdef.Location}) writeHttpResponse(decodeStoreError(err, msgID, now, nil), err) return } params := map[string]string{"url": url} if globals.mediaGcPeriod > 0 { // How long this file is guaranteed to exist without being attached to a message or a topic. params["expires"] = now.Add(globals.mediaGcPeriod).Format(types.TimeFormatRFC3339) } writeHttpResponse(NoErrParams(msgID, "", now, params), nil) logs.Info.Println("media upload: ok", fdef.Id, fdef.Location) } // LargeFileServe is the gRPC equivalent of largeFileServeHTTP. func (*grpcNodeServer) LargeFileServe(req *pbx.FileDownReq, stream pbx.Node_LargeFileServeServer) error { now := types.TimeNow() writeResponse := func(msg *ServerComMessage, err error) { stream.Send(&pbx.FileDownResp{Id: msg.Ctrl.Id, Code: int32(msg.Ctrl.Code), Text: msg.Ctrl.Text}) if err != nil { logs.Info.Println("media serve:", msg.Ctrl.Code, msg.Ctrl.Text, "/", err) } } msgID := req.GetId() // Check authorization: auth information must be present (SID is not used for gRPC). authMethod, secret := req.Auth.Scheme, req.Auth.Secret var remoteAddr string if p, ok := peer.FromContext(stream.Context()); ok { remoteAddr = p.Addr.String() } tenantID, uid, challenge, err := authFileRequest(authMethod, secret, "", remoteAddr) if err != nil { writeResponse(decodeStoreError(err, msgID, now, nil), err) return nil } if challenge != nil { writeResponse(InfoChallenge(msgID, now, challenge), nil) return nil } if uid.IsZero() { // Not authenticated writeResponse(ErrAuthRequired(msgID, "", now, now), errors.New("user not authenticated")) return nil } // Check if media handler redirects or adds headers. mh := store.Store.GetMediaHandler() url, _ := url.Parse(req.Uri) headers, statusCode, err := mh.Headers(tenantID, http.MethodGet, url, http.Header{}, true) if err != nil { writeResponse(decodeStoreError(err, "", now, nil), err) return nil } resp := pbx.FileDownResp{Meta: &pbx.FileMeta{}} if statusCode != 0 { // The handler requested to terminate further processing. resp.Code = int32(statusCode) resp.Text = http.StatusText(statusCode) resp.RedirUrl = headers.Get("Location") stream.Send(&resp) logs.Info.Println("media serve: completed with status", statusCode, "uid=", uid) return nil } fd, rsc, err := mh.Download(tenantID, req.GetUri()) if err != nil { writeResponse(decodeStoreError(err, msgID, now, nil), err) return nil } defer rsc.Close() resp.Code = http.StatusOK resp.Text = http.StatusText(http.StatusOK) resp.Meta.Name = fd.Location resp.Meta.MimeType = fd.MimeType resp.Meta.Size = fd.Size resp.Content = make([]byte, 1024*1024*2) var n int result := "OK" for { n, err = rsc.Read(resp.Content) if err == nil { resp.Content = resp.Content[:n] if err = stream.Send(&resp); err != nil { logs.Info.Println("media serve: failed, uid=", uid, err) break } continue } if err == io.EOF { err = nil } else { result = err.Error() } break } logs.Info.Println("media serve: ", result, ", uid=", uid) return err } // LargeFileReceive is the gRPC equivalent of LargeFileReceiveHTTP. func (*grpcNodeServer) LargeFileReceive(stream pbx.Node_LargeFileReceiveServer) error { now := types.TimeNow() mh := store.Store.GetMediaHandler() writeResponse := func(msg *ServerComMessage, err error) { stream.SendAndClose(&pbx.FileUpResp{Id: msg.Ctrl.Id, Code: int32(msg.Ctrl.Code), Text: msg.Ctrl.Text}) if err != nil { logs.Info.Println("media receive:", msg.Ctrl.Code, msg.Ctrl.Text, "/", err) } } req, err := stream.Recv() if err != nil { if errors.Is(err, io.EOF) { writeResponse(ErrDisconnected("", "", now), err) } else { writeResponse(decodeStoreError(err, "", now, nil), err) } return nil } msgID := req.GetId() // Check authorization: auth information must be present (SID is not used for gRPC). authMethod, secret := req.Auth.Scheme, req.Auth.Secret var remoteAddr string if p, ok := peer.FromContext(stream.Context()); ok { remoteAddr = p.Addr.String() } tenantID, uid, challenge, err := authFileRequest(authMethod, secret, "", remoteAddr) if err != nil { writeResponse(decodeStoreError(err, msgID, now, nil), err) return nil } if challenge != nil { writeResponse(InfoChallenge(msgID, now, challenge), nil) return nil } if uid.IsZero() { // Not authenticated writeResponse(ErrAuthRequired(msgID, "", now, now), errors.New("user not authenticated")) return nil } // Check if uploads are handled elsewhere. headers, statusCode, err := mh.Headers(tenantID, http.MethodPost, nil, http.Header{}, false) if err != nil { logs.Info.Println("media upload: headers check failed", err) writeResponse(decodeStoreError(err, "", now, nil), nil) return nil } if statusCode != 0 { // The handler requested to terminate further processing. err = stream.SendAndClose(&pbx.FileUpResp{ Id: msgID, Code: int32(statusCode), Text: http.StatusText(statusCode), RedirUrl: headers.Get("Location"), }) logs.Info.Println("media upload: completed with status", statusCode, "uid=", uid, err) return err } mimeType := http.DetectContentType(req.Content) // If DetectContentType fails, use client-provided content type. if mimeType == "application/octet-stream" { if contentType := req.Meta.GetMimeType(); contentType != "" { mimeType = contentType } } fdef := &types.FileDef{ ObjHeader: types.ObjHeader{ Id: store.Store.GetUidString(), }, TenantID: tenantID, User: uid.String(), MimeType: mimeType, } fdef.InitTimes() reader, writer := io.Pipe() // Create a non-blocking channel to collect errors from the inbound IO process. done := make(chan error, 1) go func() { defer writer.Close() for { if req, err := stream.Recv(); err == nil { chunk := req.GetContent() if _, err := writer.Write(chunk); err != nil { done <- err break } } else { if err == io.EOF { err = nil } done <- err break } } }() url, size, err := mh.Upload(fdef, reader) if err == nil { // No outbound IO error. Maybe we have an inbound one? err = <-done } if err != nil { logs.Info.Println("media upload: failed", req.Meta.Name, "key", fdef.Location, err) store.Files.FinishUpload(tenantID, fdef, false, 0) writeResponse(decodeStoreError(err, msgID, now, nil), nil) return nil } err = stream.SendAndClose(&pbx.FileUpResp{ Id: msgID, Code: http.StatusOK, Text: http.StatusText(http.StatusOK), Meta: &pbx.FileMeta{ Name: url, MimeType: mimeType, Etag: fdef.ETag, Size: size, }, }) logs.Info.Println("media upload: ok", fdef.Id, fdef.Location, err) return err } // largeFileRunGarbageCollection runs every 'period' and deletes up to 'blockSize' unused files. // Returns channel which can be used to stop the process. func largeFileRunGarbageCollection(period time.Duration, blockSize int) chan<- bool { // Unbuffered stop channel. Whomever stops the gc must wait for the process to finish. stop := make(chan bool) go func() { // Add some randomness to the tick period to desynchronize runs on cluster nodes: // 0.75 * period + rand(0, 0.5) * period. period = (period >> 1) + (period >> 2) + time.Duration(rand.Intn(int(period>>1))) gcTicker := time.Tick(period) for { select { case <-gcTicker: for _, tenantID := range store.KnownTenantIDs() { if err := store.Files.DeleteUnused(tenantID, time.Now().Add(-time.Hour), blockSize); err != nil { logs.Warn.Println("media gc:", tenantID, err) } } case <-stop: return } } }() return stop } // Authenticate non-websocket HTTP request func authFileRequest(authMethod, secret, sid, remoteAddr string) (types.TenantID, types.Uid, []byte, error) { var tenantID types.TenantID var uid types.Uid if authMethod != "" { decodedSecret := make([]byte, base64.StdEncoding.DecodedLen(len(secret))) n, err := base64.StdEncoding.Decode(decodedSecret, []byte(secret)) if err != nil { logs.Info.Println("media: invalid auth secret", authMethod, "'"+secret+"'") return tenantID, uid, nil, types.ErrMalformed } if authhdl := store.Store.GetLogicalAuthHandler(authMethod); authhdl != nil { rec, challenge, err := authhdl.Authenticate(auth.AuthContext{RemoteAddr: remoteAddr}, decodedSecret[:n]) if err != nil { return tenantID, uid, nil, err } if challenge != nil { return tenantID, uid, challenge, nil } if rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() { return tenantID, uid, nil, types.ErrFailed } tenantID = rec.TenantID uid = rec.Uid } else { logs.Info.Println("media: unknown auth method", authMethod) return tenantID, uid, nil, types.ErrMalformed } } else { // Find the session, make sure it's appropriately authenticated. sess := globals.sessionStore.Get(sid) if sess != nil { tenantID = sess.tenantID uid = sess.uid } } return tenantID, uid, nil, nil }