// Package media defines an interface which must be implemented by media upload/download handlers. package media import ( "errors" "io" "mime" "net/http" "net/url" "path" "regexp" "strings" "time" "slices" "github.com/tinode/chat/server/store/types" ) // ReadSeekCloser must be implemented by the media being downloaded. type ReadSeekCloser interface { io.Reader io.Seeker io.Closer } // Handler is an interface which must be implemented by media handlers (uploaders-downloaders). type Handler interface { // Init initializes the media upload handler. Init(jsconf string) error // Headers checks if the handler wants to provide additional HTTP headers for the request. // It could be CORS headers, redirect to serve files from another URL, cache-control headers. // It returns headers as a map, HTTP status code to stop processing or 0 to continue, error. Headers(tenantID types.TenantID, method string, url *url.URL, headers http.Header, serve bool) (http.Header, int, error) // Upload processes request for file upload. Returns file URL, file size, error. Upload(fdef *types.FileDef, file io.Reader) (string, int64, error) // Download processes request for file download. Download(tenantID types.TenantID, url string) (*types.FileDef, ReadSeekCloser, error) // Delete deletes file from storage. Delete(locations []string) error // GetIdFromUrl extracts file ID from download URL. GetIdFromUrl(url string) types.Uid } type PublicFileDef struct { ID string Purpose string MimeType string CreatedAt time.Time Location string ETag string } type PublicUploader interface { UploadPublic(fdef *PublicFileDef, file io.Reader) (string, int64, error) } type AllowedOrigin struct { Origin string URL url.URL HostParts []string HasWildcard bool } var fileNamePattern = regexp.MustCompile(`^[-_A-Za-z0-9]+`) var uploadExtensionMimeTypes = map[string]string{ ".aac": "audio/aac", ".m4a": "audio/x-m4a", ".doc": "application/msword", ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".ppt": "application/vnd.ms-powerpoint", ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", ".xls": "application/vnd.ms-excel", ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", } // MimeTypeForUpload picks the MIME type to persist for an uploaded file. Some common formats are // containers and are misdetected by http.DetectContentType, for example m4a as video/mp4 and Office // documents as zip, so the original file extension wins for those cases. func MimeTypeForUpload(detected, clientContentType, originalName string) string { ext := ExtensionByFilename(originalName) if extMime := uploadExtensionMimeTypes[ext]; extMime != "" { return extMime } if clientContentType != "" { return clientContentType } return strings.TrimSpace(detected) } // FileURLName returns the public URL file name for a stored file. func FileURLName(fdef *types.FileDef) string { if fdef == nil { return "" } fname := fdef.Id if ext := ExtensionByFilename(fdef.OriginalName); ext != "" { return fname + ext } return fname + ExtensionByMimeType(fdef.MimeType) } // ExtensionByFilename returns a safe lower-case extension from a client-provided file name. func ExtensionByFilename(name string) string { _, fname := path.Split(strings.TrimSpace(name)) ext := strings.ToLower(path.Ext(fname)) if len(ext) < 2 || len(ext) > 16 { return "" } for _, r := range ext[1:] { if (r < 'a' || r > 'z') && (r < '0' || r > '9') { return "" } } return ext } // ExtensionByMimeType returns a stable URL extension for uploaded media. func ExtensionByMimeType(mimeType string) string { switch strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) { case "audio/x-m4a", "audio/m4a", "audio/mp4", "audio/mp4a-latm": return ".m4a" case "audio/aac", "audio/aacp": return ".aac" case "audio/mpeg", "audio/mp3": return ".mp3" case "audio/ogg": return ".ogg" case "audio/wav", "audio/x-wav", "audio/wave": return ".wav" case "audio/webm": return ".webm" case "image/pjpeg", "image/jpg": return ".jpg" case "image/jpeg": return ".jpg" case "image/jfif": return ".jfif" case "video/mp4": return ".mp4" } exts, _ := mime.ExtensionsByType(mimeType) if len(exts) > 0 { return exts[0] } return "" } // GetIdFromUrl is a helper method for extracting file ID from a URL. func GetIdFromUrl(url, serveUrl string) types.Uid { dir, fname := path.Split(path.Clean(url)) if dir != "" && dir != serveUrl { return types.ZeroUid } return types.ParseUid(fileNamePattern.FindString(fname)) } // ParseCORSAllow pre-parses allowed origins from the configuration. func ParseCORSAllow(allowed []string) ([]AllowedOrigin, error) { if len(allowed) == 0 { return nil, nil } result := make([]AllowedOrigin, 0, len(allowed)) for _, val := range allowed { parsed := AllowedOrigin{Origin: val} switch val { case "*": if len(allowed) > 1 { return nil, errors.New("wildcard origin '*' must be the only entry") } parsed.HasWildcard = true case "": if len(allowed) > 1 { return nil, errors.New("empty allowed origin '' must be the only entry") } // Empty string means no origin allowed - no URL parsing needed parsed.HasWildcard = false default: u, err := url.ParseRequestURI(val) if err != nil { return nil, err } parsed.HostParts = strings.Split(u.Hostname(), ".") parsed.URL = *u parsed.HasWildcard = strings.Contains(u.Hostname(), "*") } result = append(result, parsed) } return result, nil } // matchCORSOrigin compares origin from the HTTP request to a list of allowed origins. func matchCORSOrigin(allowed []AllowedOrigin, origin string) string { if len(allowed) == 0 { // Not configured return "" } if origin == "" && allowed[0].Origin != "*" { // Request has no Origin header and "*" (any origin) not allowed. return "" } if allowed[0].Origin == "*" { if origin == "" { return "*" } return origin } // Check for empty string in allowed origins - this means no origin is allowed. if allowed[0].Origin == "" { return "" } originUrl, err := url.ParseRequestURI(origin) if err != nil { return "" } originParts := strings.Split(originUrl.Hostname(), ".") for _, val := range allowed { if val.Origin == origin { return origin } if !val.HasWildcard || originUrl.Scheme != val.URL.Scheme || originUrl.Port() != val.URL.Port() || len(originParts) != len(val.HostParts) { continue } matched := true for i, part := range val.HostParts { if part == "*" { continue } if part != originParts[i] { matched = false break } } if matched { return origin } } return "" } // allowMethods must be in UPPERCASE. func matchCORSMethod(allowMethods []string, method string) bool { if method == "" { // Request has no Method header. return false } return slices.Contains(allowMethods, strings.ToUpper(method)) } // CORSHandler is the default CORS processor for use by media handlers. It adds CORS headers to // preflight OPTIONS requests, Vary & Access-Control-Allow-Origin headers to all responses. func CORSHandler(method string, reqHeader http.Header, allowedOrigins []AllowedOrigin, serve bool) (http.Header, int) { respHeader := map[string][]string{ // Always add Vary because of possible intermediate caches. "Vary": {"Origin", "Access-Control-Request-Method, Access-Control-Request-Headers"}, } origin := reqHeader.Get("Origin") allowedOrigin := matchCORSOrigin(allowedOrigins, origin) if acMethod := reqHeader.Get("Access-Control-Request-Method"); method == http.MethodOptions && acMethod != "" { // Preflight request. if allowedOrigin == "" { return respHeader, http.StatusNoContent } var allowMethods []string if serve { allowMethods = []string{http.MethodGet, http.MethodHead, http.MethodOptions} } else { allowMethods = []string{http.MethodPost, http.MethodPut, http.MethodHead, http.MethodOptions} } if !matchCORSMethod(allowMethods, acMethod) { // CORS policy does not allow this method. return respHeader, http.StatusNoContent } respHeader["Access-Control-Allow-Headers"] = []string{"*"} respHeader["Access-Control-Allow-Credentials"] = []string{"true"} respHeader["Access-Control-Allow-Methods"] = []string{strings.Join(allowMethods, ", ")} respHeader["Access-Control-Max-Age"] = []string{"86400"} respHeader["Access-Control-Allow-Origin"] = []string{allowedOrigin} return respHeader, http.StatusNoContent } // Regular request, not a preflight. if allowedOrigin != "" { // Returning Origin from the actual request instead of '*', otherwise there could be an issue with Credentials. respHeader["Access-Control-Allow-Origin"] = []string{origin} } return respHeader, 0 }