Only accept video/mp4 for story uploads

This commit is contained in:
Josh Perez 2022-08-12 19:44:10 -04:00 committed by GitHub
parent 6da4b03a1e
commit 1d0b1d806a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 283 additions and 2 deletions

View File

@ -2127,6 +2127,33 @@ Signal Desktop makes use of the following open source projects.
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
## mp4box
Copyright (c) 2012. Telecom ParisTech/TSI/MM/GPAC Cyril Concolato
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the
names of its contributors may be used to endorse or promote products
derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
## mustache
The MIT License

View File

@ -7527,6 +7527,18 @@
"message": "Cant download story. You will need to share it again.",
"description": "Description for image errors but when it is your own image"
},
"StoryCreator__error--video-too-long": {
"message": "Cannot post video to story because it is too long",
"description": "Error string for when a video post to story fails"
},
"StoryCreator__error--video-unsupported": {
"message": "Cannot post video to story as it is an unsupported file format",
"description": "Error string for when a video post to story fails"
},
"StoryCreator__error--video-error": {
"message": "Failed to load video",
"description": "Error string for when a video post to story fails"
},
"StoryCreator__text-bg--background": {
"message": "Text has a white background color",
"description": "Button label"

View File

@ -127,6 +127,7 @@
"memoizee": "0.4.14",
"mkdirp": "0.5.2",
"moment": "2.29.4",
"mp4box": "0.5.2",
"mustache": "2.3.0",
"node-fetch": "2.6.7",
"node-forge": "1.3.0",

View File

@ -48,6 +48,7 @@ export default {
renderStoryViewer: { action: true },
showConversation: { action: true },
showStoriesSettings: { action: true },
showToast: { action: true },
stories: {
defaultValue: [],
},

View File

@ -15,6 +15,7 @@ import type {
import type { LocalizerType } from '../types/Util';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { PropsType as SmartStoryCreatorPropsType } from '../state/smart/StoryCreator';
import type { ShowToastActionCreatorType } from '../state/ducks/toast';
import type { ViewStoryActionCreatorType } from '../state/ducks/stories';
import { MyStories } from './MyStories';
import { StoriesPane } from './StoriesPane';
@ -35,6 +36,7 @@ export type PropsType = {
renderStoryCreator: (props: SmartStoryCreatorPropsType) => JSX.Element;
showConversation: ShowConversationType;
showStoriesSettings: () => unknown;
showToast: ShowToastActionCreatorType;
stories: Array<ConversationStoryType>;
toggleHideStories: (conversationId: string) => unknown;
toggleStoriesView: () => unknown;
@ -64,6 +66,7 @@ export const Stories = ({
renderStoryCreator,
showConversation,
showStoriesSettings,
showToast,
stories,
toggleHideStories,
toggleStoriesView,
@ -118,6 +121,7 @@ export const Stories = ({
onStoriesSettings={showStoriesSettings}
queueStoryDownload={queueStoryDownload}
showConversation={showConversation}
showToast={showToast}
stories={stories}
toggleHideStories={toggleHideStories}
toggleStoriesView={toggleStoriesView}

View File

@ -16,12 +16,18 @@ import type {
} from '../types/Stories';
import type { LocalizerType } from '../types/Util';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import type { ShowToastActionCreatorType } from '../state/ducks/toast';
import { ContextMenu } from './ContextMenu';
import { MyStoriesButton } from './MyStoriesButton';
import { SearchInput } from './SearchInput';
import { StoryListItem } from './StoryListItem';
import { Theme } from '../util/theme';
import { ToastType } from '../state/ducks/toast';
import { isNotNil } from '../util/isNotNil';
import {
isVideoGoodForStories,
ReasonVideoNotGood,
} from '../util/isVideoGoodForStories';
const FUSE_OPTIONS: Fuse.IFuseOptions<ConversationStoryType> = {
getFn: (story, path) => {
@ -70,6 +76,7 @@ export type PropsType = {
onStoriesSettings: () => unknown;
queueStoryDownload: (storyId: string) => unknown;
showConversation: ShowConversationType;
showToast: ShowToastActionCreatorType;
stories: Array<ConversationStoryType>;
toggleHideStories: (conversationId: string) => unknown;
toggleStoriesView: () => unknown;
@ -87,6 +94,7 @@ export const StoriesPane = ({
onStoriesSettings,
queueStoryDownload,
showConversation,
showToast,
stories,
toggleHideStories,
toggleStoriesView,
@ -125,15 +133,35 @@ export const StoriesPane = ({
label: i18n('Stories__add-story--media'),
onClick: () => {
const input = document.createElement('input');
input.accept = 'image/*,video/*';
input.accept = 'image/*,video/mp4';
input.type = 'file';
input.onchange = () => {
input.onchange = async () => {
const file = input.files ? input.files[0] : undefined;
if (!file) {
return;
}
const result = await isVideoGoodForStories(file);
if (
result === ReasonVideoNotGood.UnsupportedCodec ||
result === ReasonVideoNotGood.UnsupportedContainer
) {
showToast(ToastType.StoryVideoUnsupported);
return;
}
if (result === ReasonVideoNotGood.TooLong) {
showToast(ToastType.StoryVideoTooLong);
return;
}
if (result !== ReasonVideoNotGood.AllGoodNevermind) {
showToast(ToastType.StoryVideoError);
return;
}
onAddStory(file);
};
input.click();

View File

@ -50,3 +50,18 @@ export const MessageBodyTooLong = Template.bind({});
MessageBodyTooLong.args = {
toastType: ToastType.MessageBodyTooLong,
};
export const StoryVideoTooLong = Template.bind({});
StoryVideoTooLong.args = {
toastType: ToastType.StoryVideoTooLong,
};
export const StoryVideoUnsupported = Template.bind({});
StoryVideoUnsupported.args = {
toastType: ToastType.StoryVideoUnsupported,
};
export const StoryVideoError = Template.bind({});
StoryVideoError.args = {
toastType: ToastType.StoryVideoError,
};

View File

@ -63,6 +63,30 @@ export const ToastManager = ({
);
}
if (toastType === ToastType.StoryVideoTooLong) {
return (
<Toast onClose={hideToast}>
{i18n('StoryCreator__error--video-too-long')}
</Toast>
);
}
if (toastType === ToastType.StoryVideoUnsupported) {
return (
<Toast onClose={hideToast}>
{i18n('StoryCreator__error--video-unsupported')}
</Toast>
);
}
if (toastType === ToastType.StoryVideoError) {
return (
<Toast onClose={hideToast}>
{i18n('StoryCreator__error--video-error')}
</Toast>
);
}
strictAssert(
toastType === undefined,
`Unhandled toast of type: ${toastType}`

68
ts/mp4box.d.ts vendored Normal file
View File

@ -0,0 +1,68 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
declare module 'mp4box' {
interface MP4MediaTrack {
alternate_group: number;
bitrate: number;
codec: string;
created: Date;
duration: number;
id: number;
language: string;
layer: number;
modified: Date;
movie_duration: number;
nb_samples: number;
timescale: number;
track_height: number;
track_width: number;
volume: number;
}
interface MP4VideoData {
height: number;
width: number;
}
interface MP4VideoTrack extends MP4MediaTrack {
video: MP4VideoData;
}
interface MP4AudioData {
channel_count: number;
sample_rate: number;
sample_size: number;
}
interface MP4AudioTrack extends MP4MediaTrack {
audio: MP4AudioData;
}
type MP4Track = MP4VideoTrack | MP4AudioTrack;
interface MP4Info {
brands: Array<string>;
created: Date;
duration: number;
fragment_duration: number;
hasIOD: boolean;
isFragmented: boolean;
isProgressive: boolean;
mime: string;
modified: Date;
timescale: number;
tracks: Array<MP4Track>;
}
export type MP4ArrayBuffer = ArrayBuffer & { fileStart: number };
export interface MP4File {
appendBuffer(data: MP4ArrayBuffer): number;
flush(): void;
onError?: (e: string) => void;
onReady?: (info: MP4Info) => void;
}
export function createFile(): MP4File;
}

View File

@ -9,6 +9,9 @@ export enum ToastType {
StoryMuted = 'StoryMuted',
StoryReact = 'StoryReact',
StoryReply = 'StoryReply',
StoryVideoError = 'StoryVideoError',
StoryVideoTooLong = 'StoryVideoTooLong',
StoryVideoUnsupported = 'StoryVideoUnsupported',
}
// State

View File

@ -18,6 +18,7 @@ import { saveAttachment } from '../../util/saveAttachment';
import { useConversationsActions } from '../ducks/conversations';
import { useGlobalModalActions } from '../ducks/globalModals';
import { useStoriesActions } from '../ducks/stories';
import { useToastActions } from '../ducks/toast';
function renderStoryCreator({
file,
@ -31,6 +32,7 @@ export function SmartStories(): JSX.Element | null {
const { showConversation, toggleHideStories } = useConversationsActions();
const { showStoriesSettings, toggleForwardMessageModal } =
useGlobalModalActions();
const { showToast } = useToastActions();
const i18n = useSelector<StateType, LocalizerType>(getIntl);
@ -70,6 +72,7 @@ export function SmartStories(): JSX.Element | null {
renderStoryCreator={renderStoryCreator}
showConversation={showConversation}
showStoriesSettings={showStoriesSettings}
showToast={showToast}
stories={stories}
toggleHideStories={toggleHideStories}
{...storiesActions}

View File

@ -0,0 +1,90 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import MP4Box from 'mp4box';
import { VIDEO_MP4, isVideo } from '../types/MIME';
import { SECOND } from './durations';
const MAX_VIDEO_DURATION = 30 * SECOND;
type MP4ArrayBuffer = ArrayBuffer & { fileStart: number };
export enum ReasonVideoNotGood {
AllGoodNevermind = 'AllGoodNevermind',
CouldNotReadFile = 'CouldNotReadFile',
TooLong = 'TooLong',
UnsupportedCodec = 'UnsupportedCodec',
UnsupportedContainer = 'UnsupportedContainer',
}
function createMp4ArrayBuffer(src: ArrayBuffer): MP4ArrayBuffer {
const arrayBuffer = new ArrayBuffer(src.byteLength);
new Uint8Array(arrayBuffer).set(new Uint8Array(src));
(arrayBuffer as MP4ArrayBuffer).fileStart = 0;
return arrayBuffer as MP4ArrayBuffer;
}
export async function isVideoGoodForStories(
file: File
): Promise<ReasonVideoNotGood> {
if (!isVideo(file.type)) {
return ReasonVideoNotGood.AllGoodNevermind;
}
if (file.type !== VIDEO_MP4) {
return ReasonVideoNotGood.UnsupportedContainer;
}
try {
const src = await new Promise<ArrayBuffer>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (reader.result) {
resolve(reader.result as ArrayBuffer);
} else {
reject(ReasonVideoNotGood.CouldNotReadFile);
}
};
reader.readAsArrayBuffer(file);
});
const arrayBuffer = createMp4ArrayBuffer(src);
const mp4 = MP4Box.createFile();
await new Promise<void>((resolve, reject) => {
mp4.onReady = info => {
if (info.duration > MAX_VIDEO_DURATION) {
reject(ReasonVideoNotGood.TooLong);
return;
}
const codecs = /codecs="([\w,.]+)"/.exec(info.mime);
if (!codecs || !codecs[1]) {
reject(ReasonVideoNotGood.UnsupportedCodec);
return;
}
const isH264 = codecs[1]
.split(',')
.some(codec => codec.startsWith('avc1'));
if (!isH264) {
reject(ReasonVideoNotGood.UnsupportedCodec);
return;
}
resolve();
};
mp4.appendBuffer(arrayBuffer);
});
mp4.flush();
return ReasonVideoNotGood.AllGoodNevermind;
} catch (err) {
if (err instanceof Error) {
throw err;
}
return err;
}
}

View File

@ -11838,6 +11838,11 @@ move-concurrently@^1.0.1:
rimraf "^2.5.4"
run-queue "^1.0.3"
mp4box@0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/mp4box/-/mp4box-0.5.2.tgz#6a2d36fdd0e2d3f2f2bee446d2067edf0b3871bc"
integrity sha512-zRmGlvxy+YdW3Dmt+TR4xPHynbxwXtAQDTN/Fo9N3LMxaUlB2C5KmZpzYyGKy4c7k4Jf3RCR0A2pm9SZELOLXw==
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"