Fix raw GitHub URL being output to content (#2147)

* fix thumbnail quality

* Revert "fix(git-gateway): fix previews for GitHub images not in Large Media (#2125)"

This reverts commit d17f896f479292db06d3a4b39f2e51b6c41101bd.

* wip

* Stop using thunks to load media display URLs

* Revert changes to dev-test

* Revert changes to large media docs

* fix lint error

* Update docs to point to the upcoming version with non-broken media
This commit is contained in:
Shawn Erquhart 2019-03-07 21:28:14 -05:00 committed by Benaiah Mischenko
parent 40df666151
commit 37138834d6
12 changed files with 161 additions and 115 deletions

View File

@ -219,23 +219,24 @@ export default class Bitbucket {
} }
getMedia() { getMedia() {
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS); return this.api
.listAllFiles(this.config.get('media_folder'))
.then(files =>
files.map(({ id, name, path }) => ({ id, name, path, displayURL: { id, path } })),
);
}
return this.api.listAllFiles(this.config.get('media_folder')).then(files => getMediaDisplayURL(displayURL) {
files.map(({ id, name, path }) => { this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
const getDisplayURL = () => const { id, path } = displayURL;
new Promise((resolve, reject) => return new Promise((resolve, reject) =>
sem.take(() => this._mediaDisplayURLSem.take(() =>
this.api this.api
.readFile(path, id, { parseText: false }) .readFile(path, id, { parseText: false })
.then(blob => URL.createObjectURL(blob)) .then(blob => URL.createObjectURL(blob))
.then(resolve, reject) .then(resolve, reject)
.finally(() => sem.leave()), .finally(() => this._mediaDisplayURLSem.leave()),
), ),
);
return { id, name, getDisplayURL, path };
}),
); );
} }

View File

@ -214,16 +214,19 @@ export default class GitGateway {
if (!largeMediaClient.enabled) { if (!largeMediaClient.enabled) {
return mediaFiles; return mediaFiles;
} }
const largeMediaURLThunks = await this.getLargeMedia(mediaFiles); const largeMediaDisplayURLs = await this.getLargeMediaDisplayURLs(mediaFiles);
return mediaFiles.map(({ id, url, urlIsPublicPath, getDisplayURL, ...rest }) => ({ return mediaFiles.map(({ id, displayURL, path, ...rest }) => {
...rest, return {
id, ...rest,
url, id,
urlIsPublicPath: largeMediaURLThunks[id] ? false : urlIsPublicPath, path,
getDisplayURL: largeMediaURLThunks[id] displayURL: {
? largeMediaURLThunks[id] path,
: getDisplayURL || (url && (() => Promise.resolve(url))), original: displayURL,
})); largeMedia: largeMediaDisplayURLs[id],
},
};
});
}, },
); );
} }
@ -270,13 +273,13 @@ export default class GitGateway {
['backend', 'use_large_media_transforms_in_media_library'], ['backend', 'use_large_media_transforms_in_media_library'],
true, true,
) )
? { nf_resize: 'fit', w: 280, h: 160 } ? { nf_resize: 'fit', w: 560, h: 320 }
: false, : false,
}); });
}, },
); );
} }
getLargeMedia(mediaFiles) { getLargeMediaDisplayURLs(mediaFiles) {
return this.getLargeMediaClient().then(client => { return this.getLargeMediaClient().then(client => {
const largeMediaItems = mediaFiles const largeMediaItems = mediaFiles
.filter(({ path }) => client.matchPath(path)) .filter(({ path }) => client.matchPath(path))
@ -296,16 +299,35 @@ export default class GitGateway {
}), }),
) )
.then(unzip) .then(unzip)
.then(async ([idMaps, files]) => [ .then(([idMaps, files]) =>
idMaps, Promise.all([idMaps, client.getResourceDownloadURLArgs(files).then(fromPairs)]),
await client.getResourceDownloadURLThunks(files).then(fromPairs), )
])
.then(([idMaps, resourceMap]) => .then(([idMaps, resourceMap]) =>
idMaps.map(({ pointerId, resourceId }) => [pointerId, resourceMap[resourceId]]), idMaps.map(({ pointerId, resourceId }) => [pointerId, resourceMap[resourceId]]),
) )
.then(fromPairs); .then(fromPairs);
}); });
} }
getMediaDisplayURL(displayURL) {
const { path, original, largeMedia: largeMediaDisplayURL } = displayURL;
return this.getLargeMediaClient().then(client => {
if (client.enabled && client.matchPath(path)) {
return client.getDownloadURL(largeMediaDisplayURL);
}
if (this.backend.getMediaDisplayURL) {
return this.backend.getMediaDisplayURL(original);
}
const err = new Error(
`getMediaDisplayURL is not implemented by the ${
this.backendType
} backend, but the backend returned a displayURL which was not a string!`,
);
err.displayURL = displayURL;
return Promise.reject(err);
});
}
persistEntry(entry, mediaFiles, options) { persistEntry(entry, mediaFiles, options) {
return this.backend.persistEntry(entry, mediaFiles, options); return this.backend.persistEntry(entry, mediaFiles, options);
} }
@ -332,11 +354,7 @@ export default class GitGateway {
raw: pointerFileString, raw: pointerFileString,
value, value,
}; };
const persistedMediaFile = await this.backend.persistMedia(persistMediaArgument, options); return this.backend.persistMedia(persistMediaArgument, options);
return {
...persistedMediaFile,
urlIsPublicPath: false,
};
}); });
}); });
} }

View File

@ -99,10 +99,7 @@ const resourceExists = async ({ rootURL, makeAuthorizedRequest }, { sha, size })
// to fit // to fit
}; };
const getDownloadURLThunkFromSha = ( const getDownloadURL = ({ rootURL, transformImages: t, makeAuthorizedRequest }, { sha }) =>
{ rootURL, makeAuthorizedRequest, transformImages: t },
sha,
) => () =>
makeAuthorizedRequest( makeAuthorizedRequest(
`${rootURL}/origin/${sha}${ `${rootURL}/origin/${sha}${
t && Object.keys(t).length > 0 ? `?nf_resize=${t.nf_resize}&w=${t.w}&h=${t.h}` : '' t && Object.keys(t).length > 0 ? `?nf_resize=${t.nf_resize}&w=${t.w}&h=${t.h}` : ''
@ -113,17 +110,13 @@ const getDownloadURLThunkFromSha = (
.then(blob => URL.createObjectURL(blob)) .then(blob => URL.createObjectURL(blob))
.catch(err => console.error(err) || Promise.resolve('')); .catch(err => console.error(err) || Promise.resolve(''));
// We allow users to get thunks which load the blobs instead of fully const getResourceDownloadURLArgs = (clientConfig, objects) => {
// resolved blob URLs so that media clients can download the blobs return Promise.resolve(objects.map(({ sha }) => [sha, { sha }]));
// lazily. This behaves more similarly to the behavior of string };
// URLs, which only trigger an image download when the DOM element for
// that image is created.
const getResourceDownloadURLThunks = (clientConfig, objects) =>
Promise.resolve(objects.map(({ sha }) => [sha, getDownloadURLThunkFromSha(clientConfig, sha)]));
const getResourceDownloadURLs = (clientConfig, objects) => const getResourceDownloadURLs = (clientConfig, objects) =>
getResourceDownloadURLThunks(clientConfig, objects) getResourceDownloadURLArgs(clientConfig, objects)
.then(map(([sha, thunk]) => Promise.all([sha, thunk()]))) .then(map(downloadURLArg => getDownloadURL(downloadURLArg)))
.then(Promise.all.bind(Promise)); .then(Promise.all.bind(Promise));
const uploadOperation = objects => ({ const uploadOperation = objects => ({
@ -171,7 +164,8 @@ const clientFns = {
resourceExists, resourceExists,
getResourceUploadURLs, getResourceUploadURLs,
getResourceDownloadURLs, getResourceDownloadURLs,
getResourceDownloadURLThunks, getResourceDownloadURLArgs,
getDownloadURL,
uploadResource, uploadResource,
matchPath, matchPath,
}; };

View File

@ -164,7 +164,7 @@ export default class GitHub {
if (url.pathname.match(/.svg$/)) { if (url.pathname.match(/.svg$/)) {
url.search += (url.search.slice(1) === '' ? '?' : '&') + 'sanitize=true'; url.search += (url.search.slice(1) === '' ? '?' : '&') + 'sanitize=true';
} }
return { id: sha, name, size, url: url.href, urlIsPublicPath: true, path }; return { id: sha, name, size, displayURL: url.href, path };
}), }),
); );
} }
@ -178,8 +178,14 @@ export default class GitHub {
await this.api.persistFiles(null, [mediaFile], options); await this.api.persistFiles(null, [mediaFile], options);
const { sha, value, path, fileObj } = mediaFile; const { sha, value, path, fileObj } = mediaFile;
const url = URL.createObjectURL(fileObj); const displayURL = URL.createObjectURL(fileObj);
return { id: sha, name: value, size: fileObj.size, url, path: trimStart(path, '/') }; return {
id: sha,
name: value,
size: fileObj.size,
displayURL,
path: trimStart(path, '/'),
};
} catch (error) { } catch (error) {
console.error(error); console.error(error);
throw error; throw error;

View File

@ -143,33 +143,34 @@ export default class GitLab {
} }
getMedia() { getMedia() {
const sem = semaphore(MAX_CONCURRENT_DOWNLOADS);
return this.api.listAllFiles(this.config.get('media_folder')).then(files => return this.api.listAllFiles(this.config.get('media_folder')).then(files =>
files.map(({ id, name, path }) => { files.map(({ id, name, path }) => {
const getDisplayURL = () => return { id, name, path, displayURL: { id, name, path } };
new Promise((resolve, reject) =>
sem.take(() =>
this.api
.readFile(path, id, { parseText: false })
.then(blob => {
// svgs are returned with mimetype "text/plain" by gitlab
if (blob.type === 'text/plain' && name.match(/\.svg$/i)) {
return new window.Blob([blob], { type: 'image/svg+xml' });
}
return blob;
})
.then(blob => URL.createObjectURL(blob))
.then(resolve, reject)
.finally(() => sem.leave()),
),
);
return { id, name, getDisplayURL, path };
}), }),
); );
} }
getMediaDisplayURL(displayURL) {
this._mediaDisplayURLSem = this._mediaDisplayURLSem || semaphore(MAX_CONCURRENT_DOWNLOADS);
const { id, name, path } = displayURL;
return new Promise((resolve, reject) =>
this._mediaDisplayURLSem.take(() =>
this.api
.readFile(path, id, { parseText: false })
.then(blob => {
// svgs are returned with mimetype "text/plain" by gitlab
if (blob.type === 'text/plain' && name.match(/\.svg$/i)) {
return new window.Blob([blob], { type: 'image/svg+xml' });
}
return blob;
})
.then(blob => URL.createObjectURL(blob))
.then(resolve, reject)
.finally(() => this._mediaDisplayURLSem.leave()),
),
);
}
async persistEntry(entry, mediaFiles, options = {}) { async persistEntry(entry, mediaFiles, options = {}) {
return this.api.persistFiles([entry], options); return this.api.persistFiles([entry], options);
} }

View File

@ -159,16 +159,14 @@ export function persistMedia(file, opts = {}) {
try { try {
const id = await getBlobSHA(file); const id = await getBlobSHA(file);
const getDisplayURL = () => URL.createObjectURL(file); const displayURL = URL.createObjectURL(file);
const assetProxy = await createAssetProxy(fileName, file, false, privateUpload); const assetProxy = await createAssetProxy(fileName, file, false, privateUpload);
dispatch(addAsset(assetProxy)); dispatch(addAsset(assetProxy));
if (!integration) { if (!integration) {
const asset = await backend.persistMedia(state.config, assetProxy); const asset = await backend.persistMedia(state.config, assetProxy);
return dispatch(mediaPersisted({ id, getDisplayURL, ...asset })); return dispatch(mediaPersisted({ id, displayURL, ...asset }));
} }
return dispatch( return dispatch(mediaPersisted({ id, displayURL, ...assetProxy.asset }, { privateUpload }));
mediaPersisted({ id, getDisplayURL, ...assetProxy.asset }, { privateUpload }),
);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
dispatch( dispatch(
@ -231,28 +229,41 @@ export function deleteMedia(file, opts = {}) {
export function loadMediaDisplayURL(file) { export function loadMediaDisplayURL(file) {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const { getDisplayURL, id, url, urlIsPublicPath } = file; const { displayURL, id, url } = file;
const { mediaLibrary: mediaLibraryState } = getState(); const state = getState();
const displayURLPath = ['displayURLs', id]; const displayURLState = state.mediaLibrary.getIn(['displayURLs', id], Map());
const shouldLoadDisplayURL = if (
id && !id ||
((url && urlIsPublicPath) || // displayURL is used by most backends; url (like urlIsPublicPath) is used exclusively by the
(getDisplayURL && // assetStore integration. Only the assetStore uses URLs which can actually be inserted into
!mediaLibraryState.getIn([...displayURLPath, 'url']) && // an entry - other backends create a domain-relative URL using the public_folder from the
!mediaLibraryState.getIn([...displayURLPath, 'isFetching']) && // config and the file's name.
!mediaLibraryState.getIn([...displayURLPath, 'err']))); (!displayURL && !url) ||
if (shouldLoadDisplayURL) { displayURLState.get('url') ||
try { displayURLState.get('isFetching') ||
dispatch(mediaDisplayURLRequest(id)); displayURLState.get('err')
const newURL = (urlIsPublicPath && url) || (await getDisplayURL()); ) {
if (newURL) { return Promise.resolve();
dispatch(mediaDisplayURLSuccess(id, newURL)); }
return newURL; if (typeof url === 'string') {
} dispatch(mediaDisplayURLRequest(id));
return dispatch(mediaDisplayURLSuccess(id, displayURL));
}
if (typeof displayURL === 'string') {
dispatch(mediaDisplayURLRequest(id));
return dispatch(mediaDisplayURLSuccess(id, displayURL));
}
try {
const backend = currentBackend(state.config);
dispatch(mediaDisplayURLRequest(id));
const newURL = await backend.getMediaDisplayURL(displayURL);
if (newURL) {
return dispatch(mediaDisplayURLSuccess(id, newURL));
} else {
throw new Error('No display URL was returned!'); throw new Error('No display URL was returned!');
} catch (err) {
dispatch(mediaDisplayURLFailure(id, err));
} }
} catch (err) {
return dispatch(mediaDisplayURLFailure(id, err));
} }
}; };
} }
@ -322,6 +333,7 @@ export function mediaDisplayURLSuccess(key, url) {
} }
export function mediaDisplayURLFailure(key, err) { export function mediaDisplayURLFailure(key, err) {
console.error(err);
return { return {
type: MEDIA_DISPLAY_URL_FAILURE, type: MEDIA_DISPLAY_URL_FAILURE,
payload: { key, err }, payload: { key, err },

View File

@ -486,6 +486,17 @@ class Backend {
return this.implementation.getMedia(); return this.implementation.getMedia();
} }
getMediaDisplayURL(displayURL) {
if (this.implementation.getMediaDisplayURL) {
return this.implementation.getMediaDisplayURL(displayURL);
}
const err = new Error(
'getMediaDisplayURL is not implemented by the current backend, but the backend returned a displayURL which was not a string!',
);
err.displayURL = displayURL;
return Promise.reject(err);
}
entryWithFormat(collectionOrEntity) { entryWithFormat(collectionOrEntity) {
return entry => { return entry => {
const format = resolveFormat(collectionOrEntity, entry); const format = resolveFormat(collectionOrEntity, entry);

View File

@ -24,14 +24,14 @@ const IMAGE_EXTENSIONS_VIEWABLE = ['jpg', 'jpeg', 'webp', 'gif', 'png', 'bmp', '
const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE]; const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE];
const fileShape = { const fileShape = {
key: PropTypes.string.isRequired, displayURL: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
key: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
size: PropTypes.number,
queryOrder: PropTypes.number, queryOrder: PropTypes.number,
size: PropTypes.number,
url: PropTypes.string, url: PropTypes.string,
urlIsPublicPath: PropTypes.bool, urlIsPublicPath: PropTypes.bool,
getDisplayURL: PropTypes.func,
}; };
class MediaLibrary extends React.Component { class MediaLibrary extends React.Component {
@ -119,7 +119,7 @@ class MediaLibrary extends React.Component {
toTableData = files => { toTableData = files => {
const tableData = const tableData =
files && files &&
files.map(({ key, name, id, size, queryOrder, url, urlIsPublicPath, getDisplayURL }) => { files.map(({ key, name, id, size, queryOrder, url, urlIsPublicPath, displayURL }) => {
const ext = fileExtension(name).toLowerCase(); const ext = fileExtension(name).toLowerCase();
return { return {
key, key,
@ -130,7 +130,7 @@ class MediaLibrary extends React.Component {
queryOrder, queryOrder,
url, url,
urlIsPublicPath, urlIsPublicPath,
getDisplayURL, displayURL,
isImage: IMAGE_EXTENSIONS.includes(ext), isImage: IMAGE_EXTENSIONS.includes(ext),
isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext), isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext),
}; };

View File

@ -62,9 +62,9 @@ class MediaLibraryCard extends React.Component {
</Card> </Card>
); );
} }
UNSAFE_componentWillMount() { componentDidMount() {
const { displayURL, loadDisplayURL } = this.props; const { displayURL, loadDisplayURL } = this.props;
if (!displayURL || (!displayURL.url && !displayURL.isFetching && !displayURL.err)) { if (!displayURL.get('url')) {
loadDisplayURL(); loadDisplayURL();
} }
} }

View File

@ -48,7 +48,7 @@ const MediaLibraryCardGrid = ({
width={cardWidth} width={cardWidth}
margin={cardMargin} margin={cardMargin}
isPrivate={isPrivate} isPrivate={isPrivate}
displayURL={displayURLs.get(file.id, Map())} displayURL={displayURLs.get(file.id, file.url ? Map({ url: file.url }) : Map())}
loadDisplayURL={() => loadDisplayURL(file)} loadDisplayURL={() => loadDisplayURL(file)}
type={file.type} type={file.type}
/> />
@ -65,10 +65,13 @@ MediaLibraryCardGrid.propTypes = {
setScrollContainerRef: PropTypes.func.isRequired, setScrollContainerRef: PropTypes.func.isRequired,
mediaItems: PropTypes.arrayOf( mediaItems: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
displayURL: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
id: PropTypes.string.isRequired,
key: PropTypes.string.isRequired, key: PropTypes.string.isRequired,
isViewableImage: PropTypes.bool, name: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
url: PropTypes.string, url: PropTypes.string,
name: PropTypes.string, urlIsPublicPath: PropTypes.bool,
}), }),
).isRequired, ).isRequired,
isSelectedFile: PropTypes.func.isRequired, isSelectedFile: PropTypes.func.isRequired,

View File

@ -180,14 +180,14 @@ const MediaLibraryModal = ({
}; };
const fileShape = { const fileShape = {
key: PropTypes.string.isRequired, displayURL: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired,
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
key: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
size: PropTypes.number,
queryOrder: PropTypes.number, queryOrder: PropTypes.number,
size: PropTypes.number,
url: PropTypes.string, url: PropTypes.string,
urlIsPublicPath: PropTypes.bool, urlIsPublicPath: PropTypes.bool,
getDisplayURL: PropTypes.func.isRequired,
}; };
MediaLibraryModal.propTypes = { MediaLibraryModal.propTypes = {

View File

@ -6,13 +6,13 @@ weight: 10
[Netlify Large Media](https://www.netlify.com/features/large-media/) is a [Git LFS](https://git-lfs.github.com/) implementation for repositories connected to Netlify sites. This means that you can use Git to work with large asset files like images, audio, and video, without bloating your repository. It does this by replacing the asset files in your repository with text pointer files, then uploading the assets to the Netlify Large Media storage service. [Netlify Large Media](https://www.netlify.com/features/large-media/) is a [Git LFS](https://git-lfs.github.com/) implementation for repositories connected to Netlify sites. This means that you can use Git to work with large asset files like images, audio, and video, without bloating your repository. It does this by replacing the asset files in your repository with text pointer files, then uploading the assets to the Netlify Large Media storage service.
If you have a Netlify site with Large Media enabled, Netlify CMS (version 2.5.1 and above) will handle Large Media asset files seamlessly, in the same way as files stored directly in the repository. If you have a Netlify site with Large Media enabled, Netlify CMS (version 2.6.0 and above) will handle Large Media asset files seamlessly, in the same way as files stored directly in the repository.
## Requirements ## Requirements
To use Netlify Large Media with Netlify CMS, you will need to do the following: To use Netlify Large Media with Netlify CMS, you will need to do the following:
- [Upgrade Netlify CMS](/docs/update-the-cms-version/) to version 2.5.1 or above. - [Upgrade Netlify CMS](/docs/update-the-cms-version/) to version 2.6.0 or above.
- Configure Netlify CMS to use the [Git Gateway backend with Netlify Identity](/docs/authentication-backends/#git-gateway-with-netlify-identity). - Configure Netlify CMS to use the [Git Gateway backend with Netlify Identity](/docs/authentication-backends/#git-gateway-with-netlify-identity).
- Configure the Netlify site and connected repository to use Large Media, following the [Large Media docs on Netlify](https://www.netlify.com/docs/large-media/). - Configure the Netlify site and connected repository to use Large Media, following the [Large Media docs on Netlify](https://www.netlify.com/docs/large-media/).