Files
im_server/server/media/media.go
T

321 lines
8.8 KiB
Go

// 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
}
if requestedHeaders := reqHeader.Get("Access-Control-Request-Headers"); requestedHeaders != "" {
respHeader["Access-Control-Allow-Headers"] = []string{requestedHeaders}
}
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
}