diff --git a/assets/images/poster_default.jpg b/assets/images/poster_default.jpg new file mode 100644 index 00000000..c4e4e7bd Binary files /dev/null and b/assets/images/poster_default.jpg differ diff --git a/assets/ui/components/ArticleBodyHtml.jsx b/assets/ui/components/ArticleBodyHtml.jsx index df8966b0..d68191a8 100644 --- a/assets/ui/components/ArticleBodyHtml.jsx +++ b/assets/ui/components/ArticleBodyHtml.jsx @@ -5,8 +5,8 @@ import {formatHTML} from 'utils'; import {connect} from 'react-redux'; import {selectCopy} from '../../wire/actions'; import DOMPurify from 'dompurify'; - -const fallbackDefault = 'https://storage.googleapis.com/pw-prod-aap-website-bkt/test/aap_poster_default.jpg'; +// import fallbackDefault from 'images/poster_default.jpg' +const fallbackDefault = '/static/poster_default.jpg'; class ArticleBodyHtml extends React.PureComponent { constructor(props) { @@ -158,8 +158,9 @@ class ArticleBodyHtml extends React.PureComponent { document.body.removeChild(script); }; - script.onerrror = (error) => { - throw new URIError('The script ' + error.target.src + 'didn\'t load.'); + script.onerror = (error) => { + console.error('Script load error:', error); + throw new URIError('The script ' + error.target.src + ' didn\'t load.'); }; document.body.appendChild(script); @@ -230,28 +231,74 @@ class ArticleBodyHtml extends React.PureComponent { if (!videoSrc || !videoSrc.startsWith('/assets/')) { return; } - - const loadHandler = () => { - if (player.media.videoWidth === 0 && player.media.videoHeight === 0) { - if (!player.poster) { - player.poster = fallbackDefault; + // eslint-disable-next-line no-console + console.log('Initial dimensions:', player.media.videoWidth, player.media.videoHeight); + const checkVideoContent = () => { + if (player.media.videoWidth > 0 && player.media.videoHeight > 0) { + const canvas = document.createElement('canvas'); + canvas.width = player.media.videoWidth; + canvas.height = player.media.videoHeight; + const ctx = canvas.getContext('2d'); + + ctx.drawImage(player.media, 0, 0, canvas.width, canvas.height); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = imageData.data; + // loop for none blank pixel + let stepSize = 4; // Adjust the step size as needed + for (let i = 0; i < data.length; i += stepSize * 4) { + if (data[i] > 0 || data[i + 1] > 0 || data[i + 2] > 0) { + + // eslint-disable-next-line no-console + console.log('Pixel content detected, poster not needed'); + return true; + } + } + } + return false; + }; + + const attemptContentCheck = () => { + if (checkVideoContent()) { + player.poster = null; + // eslint-disable-next-line no-console + console.log('Pixel content detected, poster removed'); + return true; } - } else { - const isFirstFrameBlack = player.media.videoWidth === 1920 && player.media.videoHeight === 1080; - if (!isFirstFrameBlack) - if (!player.poster) { + return false; + }; + + let attemptCount = 0; + const maxAttempts = 2; + const checkInterval = setInterval(() => { + if (attemptContentCheck() || attemptCount >= maxAttempts) { + clearInterval(checkInterval); + player.off('loadeddata', loadHandler); + + if (attemptCount >= maxAttempts) { + console.warn('Setting fallback poster'); player.poster = fallbackDefault; } - } - player.off('loadeddata', loadHandler); + } + attemptCount++; + }, 500); }; + player.on('error', (error) => { + console.error('Error details:', { + message: error.message, + code: error.code, + type: error.type, + target: error.target, + currentTarget: error.currentTarget, + originalTarget: error.originalTarget, + error: error.error + }); + player.poster = fallbackDefault; + }); player.on('loadeddata', loadHandler); - } - _getBodyHTML(bodyHtml) { return !bodyHtml ? null : @@ -261,7 +308,6 @@ class ArticleBodyHtml extends React.PureComponent { _updateImageEmbedSources(html) { const item = this.props.item; - // Get the list of Original Rendition IDs for all Image Associations const imageEmbedOriginalIds = Object .keys(item.associations || {}) .filter((key) => key.startsWith('editor_')) @@ -269,13 +315,9 @@ class ArticleBodyHtml extends React.PureComponent { .filter((value) => value); if (!imageEmbedOriginalIds.length) { - // This item has no Image Embeds - // return the supplied html as-is return html; } - // Create a DOM node tree from the supplied html - // We can then efficiently find and update the image sources const container = document.createElement('div'); let imageSourcesUpdated = false; @@ -283,21 +325,17 @@ class ArticleBodyHtml extends React.PureComponent { container .querySelectorAll('img,video,audio') .forEach((imageTag) => { - // Using the tag's `src` attribute, find the Original Rendition's ID const originalMediaId = imageEmbedOriginalIds.find((mediaId) => ( !imageTag.src.startsWith('/assets/') && imageTag.src.includes(mediaId)) ); if (originalMediaId) { - // We now have the Original Rendition's ID - // Use that to update the `src` attribute to use Newshub's Web API imageSourcesUpdated = true; imageTag.src = `/assets/${originalMediaId}`; } }); - // Find all Audio and Video tags and mark them up for the player container.querySelectorAll('video, audio') .forEach((vTag) => { vTag.classList.add('js-player'); @@ -314,7 +352,6 @@ class ArticleBodyHtml extends React.PureComponent { } imageSourcesUpdated = true; }); - // If Image tags were not updated, then return the supplied html as-is return imageSourcesUpdated ? container.innerHTML : html; @@ -326,7 +363,6 @@ class ArticleBodyHtml extends React.PureComponent { if (target && target.tagName === 'A' && this.isLinkExternal(target.href)) { event.preventDefault(); event.stopPropagation(); - const nextWindow = window.open(target.href, '_blank', 'noopener'); if (nextWindow) { diff --git a/newsroom/static/poster_default.jpg b/newsroom/static/poster_default.jpg new file mode 100644 index 00000000..c4e4e7bd Binary files /dev/null and b/newsroom/static/poster_default.jpg differ diff --git a/newsroom/upload.py b/newsroom/upload.py index 3d179b6b..6da0c9f4 100644 --- a/newsroom/upload.py +++ b/newsroom/upload.py @@ -1,21 +1,36 @@ - import flask import newsroom import bson.errors from werkzeug.wsgi import wrap_file +from werkzeug.http import parse_range_header from werkzeug.utils import secure_filename from flask import request, url_for, current_app as newsroom_app from superdesk.upload import upload_url as _upload_url from superdesk import get_resource_service from newsroom.decorator import login_required - cache_for = 3600 * 24 * 7 # 7 days cache ASSETS_RESOURCE = 'upload' blueprint = flask.Blueprint(ASSETS_RESOURCE, __name__) +class MediaFileLoader: + _loaded_files = {} + + @classmethod + def get_media_file(cls, media_id): + if media_id in cls._loaded_files: + return cls._loaded_files[media_id] + + media_file = flask.current_app.media.get(media_id, ASSETS_RESOURCE) + + if media_file and 'video' in media_file.content_type: + cls._loaded_files[media_id] = media_file + + return media_file + + def get_file(key): file = request.files.get(key) if file: @@ -27,19 +42,68 @@ def get_file(key): @blueprint.route('/assets/', methods=['GET']) @login_required def get_upload(media_id): + is_safari = ('Safari' in request.headers.get('User-Agent', '') and 'Chrome' + not in request.headers.get('User-Agent', '')) try: - media_file = flask.current_app.media.get(media_id, ASSETS_RESOURCE) + if is_safari: + media_file = flask.current_app.media.get(media_id, ASSETS_RESOURCE) + else: + media_file = MediaFileLoader.get_media_file(media_id) except bson.errors.InvalidId: media_file = None if not media_file: - flask.abort(404) - - data = wrap_file(flask.request.environ, media_file, buffer_size=1024 * 256) - response = flask.current_app.response_class( - data, - mimetype=media_file.content_type, - direct_passthrough=True) - response.content_length = media_file.length + flask.abort(404, description="File not found") + + file_size = media_file.length + content_type = media_file.content_type or 'application/octet-stream' + range_header = request.headers.get('Range') + if not is_safari and range_header: + try: + ranges = parse_range_header(range_header) + if ranges and len(ranges.ranges) == 1: + start, end = ranges.ranges[0] + if start is None: + flask.abort(416, description="Invalid range header") + if end is None or end >= file_size: + end = file_size - 1 + length = end - start + 1 + + def range_generate(): + media_file.seek(start) + remaining = length + chunk_size = 8192 + while remaining: + chunk = media_file.read(min(chunk_size, remaining)) + if not chunk: + break + remaining -= len(chunk) + yield chunk + + response = flask.Response( + flask.stream_with_context(range_generate()), + 206, + mimetype=content_type, + direct_passthrough=True, + ) + response.headers.add('Content-Range', f'bytes {start}-{end}/{file_size}') + response.headers.add('Accept-Ranges', 'bytes') + response.headers.add('Content-Length', str(length)) + else: + flask.abort(416, description="Requested range not satisfiable") + except ValueError: + flask.abort(400, description="Invalid range header") + else: + data = wrap_file(flask.request.environ, media_file, buffer_size=1024 * 256) + response = flask.current_app.response_class( + data, + mimetype=media_file.content_type, + direct_passthrough=True) + response.content_length = media_file.length + + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = 'GET, OPTIONS' + response.headers.pop('Content-Disposition', None) + response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' response.last_modified = media_file.upload_date response.set_etag(media_file.md5) response.cache_control.max_age = cache_for @@ -47,15 +111,17 @@ def get_upload(media_id): response.cache_control.public = True response.make_conditional(flask.request) - if flask.request.args.get('filename'): - response.headers['Content-Type'] = media_file.content_type - response.headers['Content-Disposition'] = 'attachment; filename="%s"' % flask.request.args['filename'] + if request.args.get('filename'): + response.headers['Content-Disposition'] = f'attachment; filename="{request.args["filename"]}"' else: response.headers['Content-Disposition'] = 'inline' item_id = request.args.get('item_id') if item_id: - get_resource_service('history').log_media_download(item_id, media_id) + try: + get_resource_service('history').log_media_download(item_id, media_id) + except Exception as e: + newsroom_app.logger.error(f"Error logging media download: {str(e)}") return response diff --git a/package-lock.json b/package-lock.json index 3d532542..3cc8044c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3559,6 +3559,52 @@ "object-assign": "^4.0.1" } }, + "file-loader": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-1.1.11.tgz", + "integrity": "sha512-TGR4HU7HUsGg6GCOPJnFk06RhWgEWFLAGWiT6rcD+GRC2keU3s9RGJ+b3Z6/U73jwwNb2gKLJ7YCrp+jvU4ALg==", + "dev": true, + "requires": { + "loader-utils": "^1.0.2", + "schema-utils": "^0.4.5" + }, + "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "schema-utils": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.7.tgz", + "integrity": "sha512-v/iwU6wvwGK8HbU9yi3/nhGzP0yGSuhQMzL6ySiec1FSrZZDkhm4noOSWzrNFo/jEc+SJY6jRTwuwbSXJPDUnQ==", + "dev": true, + "requires": { + "ajv": "^6.1.0", + "ajv-keywords": "^3.1.0" + } + } + } + }, "file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", diff --git a/package.json b/package.json index 73bd7c2d..02820f73 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "eslint": "^4.8.0", "eslint-plugin-react": "^7.3.0", "expect": "^21.1.0", + "file-loader": "^1.1.11", "karma": "^1.7.1", "karma-chrome-launcher": "^2.2.0", "karma-jasmine": "^1.1.0", diff --git a/webpack.config.js b/webpack.config.js index 86a166c7..dec4e123 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,5 +1,3 @@ -/* eslint-env node */ - const path = require('path'); const webpack = require('webpack'); const ManifestPlugin = require('webpack-manifest-plugin'); @@ -8,6 +6,7 @@ const NODE_MODULES = process.env.NODE_MODULES || 'node_modules'; module.exports = { entry: { newsroom_js: './assets/index.js', + newsroom_images: './assets/images.js', companies_js: './assets/companies/index.js', users_js: './assets/users/index.js', products_js: './assets/products/index.js', @@ -76,11 +75,26 @@ module.exports = { 'sass-loader', ], }, + { + test: /\.(png|jpe?g|gif|svg)$/i, + use: [ + { + loader: 'file-loader', + options: { + name: '[name].[hash].[ext]', + }, + }, + ], + }, + ] }, resolve: { - extensions: ['.js', '.jsx'], + extensions: ['.js', '.jsx', '.json', '.png', '.jpg', '.gif', '.svg'], modules: [path.resolve(__dirname, 'assets'), NODE_MODULES], + alias: { + assets: path.resolve(__dirname, 'assets'), + }, }, resolveLoader: { modules: [NODE_MODULES],