321 lines
8.8 KiB
Go
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
|
|
}
|