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

@ -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;
}
}