package mediaapi import ( "context" "errors" "fmt" "mime" "net/http" "strings" "time" "github.com/dariuszjaniak/warp/server/internal/federation" "github.com/dariuszjaniak/warp/server/internal/httputil" "github.com/dariuszjaniak/warp/server/internal/storage" ) // fetchTimeout bounds one fetch from another server, waiting included. // It is detached from the request that started it: other requests may be // waiting on the same fetch, and what it fetches is kept either way. const fetchTimeout = 10 * time.Minute // remoteMedia returns another server's media from the cache, fetching it // first if it is not there. func (a *API) remoteMedia(ctx context.Context, origin, mediaID string, wait time.Duration) (storage.Media, error) { m, err := a.store.Media(ctx, origin, mediaID) if err == nil { return m, nil } if !errors.Is(err, storage.ErrNoSuchMedia) { return storage.Media{}, err } remote := a.remoteClient() if remote == nil { return storage.Media{}, ErrNotFound } wait = min(max(wait, 0), MaxWait) v, err := a.shared(ctx, "download\x00"+mediaName(origin, mediaID), func(ctx context.Context) (any, error) { return a.fetchRemote(ctx, remote, origin, mediaID, wait) }) if err != nil { return storage.Media{}, err } return v.(storage.Media), nil } // shared runs fn once for every concurrent caller with the same key, on a // context of its own, and lets each caller stop waiting when its own // request ends. func (a *API) shared(ctx context.Context, key string, fn func(context.Context) (any, error)) (any, error) { result := a.fetches.DoChan(key, func() (any, error) { fctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), fetchTimeout) defer cancel() return fn(fctx) }) select { case r := <-result: return r.Val, r.Err case <-ctx.Done(): return nil, ctx.Err() } } // fetchRemote downloads media from its server and caches it. func (a *API) fetchRemote(ctx context.Context, remote Remote, origin, mediaID string, wait time.Duration) (storage.Media, error) { resp, err := remote.DownloadMedia(ctx, origin, mediaID, wait) if err != nil { return storage.Media{}, a.remoteError(origin, mediaID, err) } defer func() { _ = resp.Body.Close() }() blob, err := a.blobs.Put(ctx, resp.Body, a.cfg.MaxUploadBytes) switch { case errors.Is(err, storage.ErrBlobTooLarge): return storage.Media{}, ErrTooLarge case err != nil: a.log.Warn("could not fetch remote media", "media", mediaName(origin, mediaID), "error", err) return storage.Media{}, fmt.Errorf("%w: %v", ErrRemote, err) } m := storage.Media{ Origin: origin, MediaID: mediaID, ContentType: normaliseContentType(resp.ContentType), UploadName: fileNameFrom(resp.ContentDisposition), Size: blob.Size, SHA256: blob.SHA256, CreatedAtMS: a.nowMS(), } err = a.store.CreateMedia(ctx, m) if errors.Is(err, storage.ErrMediaExists) { // Fetched twice — by another process, or before a restart. The // copy already recorded is the same content. return a.store.Media(ctx, origin, mediaID) } if err != nil { return storage.Media{}, err } a.log.Debug("fetched remote media", "media", mediaName(origin, mediaID), "content_type", m.ContentType, "bytes", m.Size) return m, nil } // fetchRemoteThumbnail asks the media's server for a thumbnail of one of // the configured sizes, and caches it under that size. // // The size asked for is this server's, not the client's: the cache is // keyed by it, and a client asking for 33 × 33 and another for 34 × 34 // share one thumbnail rather than causing two fetches. func (a *API) fetchRemoteThumbnail(ctx context.Context, remote Remote, origin, mediaID string, size thumbnailSize, wait time.Duration) (storage.Thumbnail, error) { resp, err := remote.MediaThumbnail(ctx, origin, mediaID, federation.ThumbnailRequest{ Width: size.Width, Height: size.Height, Method: size.Method, }, wait) if err != nil { var httpErr *federation.HTTPError if errors.As(err, &httpErr) && httpErr.Status == http.StatusBadRequest { return storage.Thumbnail{}, ErrCannotThumbnail } return storage.Thumbnail{}, a.remoteError(origin, mediaID, err) } defer func() { _ = resp.Body.Close() }() contentType := normaliseContentType(resp.ContentType) if !strings.HasPrefix(contentType, "image/") { // A thumbnail that is not an image is not one, whatever the // other server meant by it. return storage.Thumbnail{}, fmt.Errorf("%w: thumbnail of type %q", ErrRemote, contentType) } blob, err := a.blobs.Put(ctx, resp.Body, a.cfg.MaxUploadBytes) switch { case errors.Is(err, storage.ErrBlobTooLarge): return storage.Thumbnail{}, ErrTooLarge case err != nil: return storage.Thumbnail{}, fmt.Errorf("%w: %v", ErrRemote, err) } t := storage.Thumbnail{ Origin: origin, MediaID: mediaID, Width: size.Width, Height: size.Height, Method: size.Method, ContentType: contentType, Size: blob.Size, SHA256: blob.SHA256, CreatedAtMS: a.nowMS(), } if err := a.store.StoreThumbnail(ctx, t); err != nil { return storage.Thumbnail{}, err } return t, nil } // remoteError maps a failed fetch onto the API's errors. func (a *API) remoteError(origin, mediaID string, err error) error { var httpErr *federation.HTTPError switch { case errors.Is(err, federation.ErrDisabled), errors.Is(err, federation.ErrNotAllowed): return ErrFederationDisabled case errors.As(err, &httpErr) && httpErr.Status == http.StatusNotFound: return ErrNotFound case errors.As(err, &httpErr) && httpErr.Code == httputil.ErrCodeNotYetUploaded: return ErrNotYetUploaded case errors.As(err, &httpErr) && (httpErr.Status == http.StatusRequestEntityTooLarge || httpErr.Code == httputil.ErrCodeTooLarge): return ErrTooLarge } a.log.Warn("could not fetch remote media", "media", mediaName(origin, mediaID), "error", err) return fmt.Errorf("%w: %v", ErrRemote, err) } // fileNameFrom reads the file name out of a Content-Disposition header. func fileNameFrom(disposition string) string { if disposition == "" { return "" } _, params, err := mime.ParseMediaType(disposition) if err != nil { return "" } name := params["filename"] // A file name is a name, not a path: keep only the last element of // one a server sent with directories in it. if i := strings.LastIndexAny(name, `/\`); i >= 0 { name = name[i+1:] } return name }