Warnings for dangerous files
This commit is contained in:
parent
3b8f934741
commit
ca61c9cb85
15 changed files with 232 additions and 13 deletions
|
@ -546,6 +546,11 @@
|
||||||
"message": "Unsupported file type",
|
"message": "Unsupported file type",
|
||||||
"description": "Displayed for outgoing unsupported attachment"
|
"description": "Displayed for outgoing unsupported attachment"
|
||||||
},
|
},
|
||||||
|
"dangerousFileType": {
|
||||||
|
"message": "Attachment type not allowed for security reasons",
|
||||||
|
"description":
|
||||||
|
"Shown in toast when user attempts to send .exe file, for example"
|
||||||
|
},
|
||||||
"fileSizeWarning": {
|
"fileSizeWarning": {
|
||||||
"message": "Sorry, the selected file exceeds message size restrictions."
|
"message": "Sorry, the selected file exceeds message size restrictions."
|
||||||
},
|
},
|
||||||
|
|
22
images/error-filled.svg
Normal file
22
images/error-filled.svg
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<!-- Generator: Sketch 51.3 (57544) - http://www.bohemiancoding.com/sketch -->
|
||||||
|
<title>Error/error-filled-16</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<defs>
|
||||||
|
<path d="M8,1 C11.864,1 15,4.136 15,8 C15,11.864 11.864,15 8,15 C4.136,15 1,11.864 1,8 C1,4.136 4.136,1 8,1 Z M8,3.5 C7.98736684,3.5 7.9747349,3.50024902 7.96211155,3.50074693 C7.43159364,3.52167214 7.01848713,3.96870512 7.03941235,4.49922303 L7.20654214,8.7364722 C7.22336326,9.16293903 7.57398102,9.5 8.00077946,9.5 C8.42754697,9.5 8.77810943,9.16290468 8.79481871,8.73646441 L8.96084687,4.49923322 C8.96133837,4.48668956 8.96158419,4.47413748 8.96158419,4.46158419 C8.96158419,3.93051591 8.53106829,3.5 8,3.5 Z M8,10.5 C7.44771525,10.5 7,10.9477153 7,11.5 C7,12.0522847 7.44771525,12.5 8,12.5 C8.55228475,12.5 9,12.0522847 9,11.5 C9,10.9477153 8.55228475,10.5 8,10.5 Z" id="path-1"></path>
|
||||||
|
<rect id="path-3" x="0" y="0" width="16.1006289" height="16.1006289"></rect>
|
||||||
|
</defs>
|
||||||
|
<g id="Error/error-filled-16" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<mask id="mask-2" fill="white">
|
||||||
|
<use xlink:href="#path-1"></use>
|
||||||
|
</mask>
|
||||||
|
<use id="Combined-Shape" fill="#FF261F" fill-rule="nonzero" xlink:href="#path-1"></use>
|
||||||
|
<g id="Color/UI/Black" mask="url(#mask-2)">
|
||||||
|
<mask id="mask-4" fill="white">
|
||||||
|
<use xlink:href="#path-3"></use>
|
||||||
|
</mask>
|
||||||
|
<use id="fill" fill="#000000" fill-rule="evenodd" xlink:href="#path-3"></use>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
|
@ -439,10 +439,11 @@
|
||||||
message: this,
|
message: this,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
onDownload: () =>
|
onDownload: isDangerous =>
|
||||||
this.trigger('download', {
|
this.trigger('download', {
|
||||||
attachment: firstAttachment,
|
attachment: firstAttachment,
|
||||||
message: this,
|
message: this,
|
||||||
|
isDangerous,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
|
@ -108,6 +108,29 @@ exports._replaceUnicodeOrderOverridesSync = attachment => {
|
||||||
exports.replaceUnicodeOrderOverrides = async attachment =>
|
exports.replaceUnicodeOrderOverrides = async attachment =>
|
||||||
exports._replaceUnicodeOrderOverridesSync(attachment);
|
exports._replaceUnicodeOrderOverridesSync(attachment);
|
||||||
|
|
||||||
|
// \u202A-\u202E is LRE, RLE, PDF, LRO, RLO
|
||||||
|
// \u2066-\u2069 is LRI, RLI, FSI, PDI
|
||||||
|
// \u200E is LRM
|
||||||
|
// \u200F is RLM
|
||||||
|
// \u061C is ALM
|
||||||
|
const V2_UNWANTED_UNICODE = /[\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C]/g;
|
||||||
|
|
||||||
|
exports.replaceUnicodeV2 = async attachment => {
|
||||||
|
if (!is.string(attachment.fileName)) {
|
||||||
|
return attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = attachment.fileName.replace(
|
||||||
|
V2_UNWANTED_UNICODE,
|
||||||
|
UNICODE_REPLACEMENT_CHARACTER
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...attachment,
|
||||||
|
fileName,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
exports.removeSchemaVersion = ({ attachment, logger }) => {
|
exports.removeSchemaVersion = ({ attachment, logger }) => {
|
||||||
if (!exports.isValid(attachment)) {
|
if (!exports.isValid(attachment)) {
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
|
@ -44,6 +44,9 @@ const PRIVATE = 'private';
|
||||||
// Version 8
|
// Version 8
|
||||||
// - Attachments: Capture video/image dimensions and thumbnails, as well as a
|
// - Attachments: Capture video/image dimensions and thumbnails, as well as a
|
||||||
// full-size screenshot for video.
|
// full-size screenshot for video.
|
||||||
|
// Version 9
|
||||||
|
// - Attachments: Expand the set of unicode characters we filter out of
|
||||||
|
// attachment filenames
|
||||||
|
|
||||||
const INITIAL_SCHEMA_VERSION = 0;
|
const INITIAL_SCHEMA_VERSION = 0;
|
||||||
|
|
||||||
|
@ -270,6 +273,11 @@ const toVersion8 = exports._withSchemaVersion({
|
||||||
upgrade: exports._mapAttachments(Attachment.captureDimensionsAndScreenshot),
|
upgrade: exports._mapAttachments(Attachment.captureDimensionsAndScreenshot),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const toVersion9 = exports._withSchemaVersion({
|
||||||
|
schemaVersion: 9,
|
||||||
|
upgrade: exports._mapAttachments(Attachment.replaceUnicodeV2),
|
||||||
|
});
|
||||||
|
|
||||||
const VERSIONS = [
|
const VERSIONS = [
|
||||||
toVersion0,
|
toVersion0,
|
||||||
toVersion1,
|
toVersion1,
|
||||||
|
@ -280,6 +288,7 @@ const VERSIONS = [
|
||||||
toVersion6,
|
toVersion6,
|
||||||
toVersion7,
|
toVersion7,
|
||||||
toVersion8,
|
toVersion8,
|
||||||
|
toVersion9,
|
||||||
];
|
];
|
||||||
exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1;
|
exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1;
|
||||||
|
|
||||||
|
|
|
@ -1057,7 +1057,14 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
downloadAttachment({ attachment, message }) {
|
downloadAttachment({ attachment, message, isDangerous }) {
|
||||||
|
if (isDangerous) {
|
||||||
|
const toast = new Whisper.DangerousFileTypeToast();
|
||||||
|
toast.$el.appendTo(this.$el);
|
||||||
|
toast.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Signal.Types.Attachment.save({
|
Signal.Types.Attachment.save({
|
||||||
attachment,
|
attachment,
|
||||||
document,
|
document,
|
||||||
|
|
|
@ -34,6 +34,10 @@
|
||||||
template: i18n('unsupportedFileType'),
|
template: i18n('unsupportedFileType'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({
|
||||||
|
template: i18n('dangerousFileType'),
|
||||||
|
});
|
||||||
|
|
||||||
Whisper.FileInputView = Backbone.View.extend({
|
Whisper.FileInputView = Backbone.View.extend({
|
||||||
tagName: 'span',
|
tagName: 'span',
|
||||||
className: 'file-input',
|
className: 'file-input',
|
||||||
|
@ -178,6 +182,14 @@
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const { name } = file;
|
||||||
|
if (window.Signal.Util.isFileDangerous(name)) {
|
||||||
|
const toast = new Whisper.DangerousFileTypeToast();
|
||||||
|
toast.$el.insertAfter(this.$el);
|
||||||
|
toast.render();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const contentType = file.type;
|
const contentType = file.type;
|
||||||
|
|
||||||
|
@ -297,9 +309,10 @@
|
||||||
|
|
||||||
getFile(rawFile) {
|
getFile(rawFile) {
|
||||||
const file = rawFile || this.file || this.$input.prop('files')[0];
|
const file = rawFile || this.file || this.$input.prop('files')[0];
|
||||||
if (file === undefined) {
|
if (!file) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachmentFlags = this.isVoiceNote
|
const attachmentFlags = this.isVoiceNote
|
||||||
? textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE
|
? textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE
|
||||||
: null;
|
: null;
|
||||||
|
|
|
@ -316,7 +316,7 @@
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
|
|
||||||
background-color: $color-light-60;
|
background-color: $color-gray-75;
|
||||||
color: $color-white;
|
color: $color-white;
|
||||||
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
|
@ -345,6 +345,10 @@
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-message__generic-attachment__icon-container {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.module-message__generic-attachment__icon {
|
.module-message__generic-attachment__icon {
|
||||||
background: url('../images/file-gradient.svg') no-repeat center;
|
background: url('../images/file-gradient.svg') no-repeat center;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
@ -359,6 +363,26 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-message__generic-attachment__icon-dangerous-container {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: -1px;
|
||||||
|
right: -4px;
|
||||||
|
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: $color-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-message__generic-attachment__icon-dangerous {
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
|
||||||
|
@include color-svg('../images/error-filled.svg', $color-core-red);
|
||||||
|
}
|
||||||
|
|
||||||
.module-message__generic-attachment__icon__extension {
|
.module-message__generic-attachment__icon__extension {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
line-height: 13px;
|
line-height: 13px;
|
||||||
|
|
|
@ -62,7 +62,7 @@ body.dark-theme {
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
background-color: $color-light-60;
|
background-color: $color-gray-45;
|
||||||
color: $color-white;
|
color: $color-white;
|
||||||
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.12),
|
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.12),
|
||||||
0 0 0 0.5px rgba(0, 0, 0, 0.08);
|
0 0 0 0.5px rgba(0, 0, 0, 0.08);
|
||||||
|
|
|
@ -83,6 +83,50 @@ describe('Attachment', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('replaceUnicodeV2', () => {
|
||||||
|
it('should remove all bad characters', async () => {
|
||||||
|
const input = {
|
||||||
|
size: 1111,
|
||||||
|
fileName:
|
||||||
|
'file\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069\u200E\u200F\u061C.jpeg',
|
||||||
|
};
|
||||||
|
const expected = {
|
||||||
|
fileName:
|
||||||
|
'file\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD.jpeg',
|
||||||
|
size: 1111,
|
||||||
|
};
|
||||||
|
|
||||||
|
const actual = await Attachment.replaceUnicodeV2(input);
|
||||||
|
assert.deepEqual(actual, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should should leave normal filename alone', async () => {
|
||||||
|
const input = {
|
||||||
|
fileName: 'normal.jpeg',
|
||||||
|
size: 1111,
|
||||||
|
};
|
||||||
|
const expected = {
|
||||||
|
fileName: 'normal.jpeg',
|
||||||
|
size: 1111,
|
||||||
|
};
|
||||||
|
|
||||||
|
const actual = await Attachment.replaceUnicodeV2(input);
|
||||||
|
assert.deepEqual(actual, expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing fileName', async () => {
|
||||||
|
const input = {
|
||||||
|
size: 1111,
|
||||||
|
};
|
||||||
|
const expected = {
|
||||||
|
size: 1111,
|
||||||
|
};
|
||||||
|
|
||||||
|
const actual = await Attachment.replaceUnicodeV2(input);
|
||||||
|
assert.deepEqual(actual, expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('removeSchemaVersion', () => {
|
describe('removeSchemaVersion', () => {
|
||||||
it('should remove existing schema version', () => {
|
it('should remove existing schema version', () => {
|
||||||
const input = {
|
const input = {
|
||||||
|
|
|
@ -1922,6 +1922,48 @@ Voice notes are not shown any differently from audio attachments.
|
||||||
</util.ConversationContext>
|
</util.ConversationContext>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Dangerous file type
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<util.ConversationContext theme={util.theme}>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
conversationColor="green"
|
||||||
|
direction="incoming"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
attachment={{
|
||||||
|
url: util.txtObjectUrl,
|
||||||
|
contentType: 'text/plain',
|
||||||
|
fileName: 'blah.exe',
|
||||||
|
fileSize: '3.05 KB',
|
||||||
|
}}
|
||||||
|
onClickAttachment={isDangerous =>
|
||||||
|
console.log('onClickAttachment - isDangerous:', isDangerous)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Message
|
||||||
|
conversationColor="green"
|
||||||
|
direction="outgoing"
|
||||||
|
i18n={util.i18n}
|
||||||
|
timestamp={Date.now()}
|
||||||
|
status="sent"
|
||||||
|
attachment={{
|
||||||
|
url: util.txtObjectUrl,
|
||||||
|
contentType: 'text/plain',
|
||||||
|
fileName: 'blah.exe',
|
||||||
|
fileSize: '3.05 KB',
|
||||||
|
}}
|
||||||
|
onClickAttachment={isDangerous =>
|
||||||
|
console.log('onClickAttachment - isDangerous:', isDangerous)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</util.ConversationContext>
|
||||||
|
```
|
||||||
|
|
||||||
### In a group conversation
|
### In a group conversation
|
||||||
|
|
||||||
Note that the author avatar goes away if `collapseMetadata` is set.
|
Note that the author avatar goes away if `collapseMetadata` is set.
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { ContactName } from './ContactName';
|
||||||
import { Quote, QuotedAttachment } from './Quote';
|
import { Quote, QuotedAttachment } from './Quote';
|
||||||
import { EmbeddedContact } from './EmbeddedContact';
|
import { EmbeddedContact } from './EmbeddedContact';
|
||||||
|
|
||||||
|
import { isFileDangerous } from '../../util/isFileDangerous';
|
||||||
import { Contact } from '../../types/Contact';
|
import { Contact } from '../../types/Contact';
|
||||||
import { Color, Localizer } from '../../types/Util';
|
import { Color, Localizer } from '../../types/Util';
|
||||||
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
|
import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu';
|
||||||
|
@ -87,7 +88,7 @@ export interface Props {
|
||||||
onClickAttachment?: () => void;
|
onClickAttachment?: () => void;
|
||||||
onReply?: () => void;
|
onReply?: () => void;
|
||||||
onRetrySend?: () => void;
|
onRetrySend?: () => void;
|
||||||
onDownload?: () => void;
|
onDownload?: (isDangerous: boolean) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onShowDetail: () => void;
|
onShowDetail: () => void;
|
||||||
}
|
}
|
||||||
|
@ -363,7 +364,7 @@ export class Message extends React.Component<Props, State> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// tslint:disable-next-line max-func-body-length cyclomatic-complexity
|
// tslint:disable-next-line max-func-body-length cyclomatic-complexity jsx-no-lambda react-this-binding-issue
|
||||||
public renderAttachment() {
|
public renderAttachment() {
|
||||||
const {
|
const {
|
||||||
i18n,
|
i18n,
|
||||||
|
@ -503,6 +504,7 @@ export class Message extends React.Component<Props, State> {
|
||||||
} else {
|
} else {
|
||||||
const { fileName, fileSize, contentType } = attachment;
|
const { fileName, fileSize, contentType } = attachment;
|
||||||
const extension = getExtension({ contentType, fileName });
|
const extension = getExtension({ contentType, fileName });
|
||||||
|
const isDangerous = isFileDangerous(fileName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
@ -516,10 +518,17 @@ export class Message extends React.Component<Props, State> {
|
||||||
: null
|
: null
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="module-message__generic-attachment__icon">
|
<div className="module-message__generic-attachment__icon-container">
|
||||||
{extension ? (
|
<div className="module-message__generic-attachment__icon">
|
||||||
<div className="module-message__generic-attachment__icon__extension">
|
{extension ? (
|
||||||
{extension}
|
<div className="module-message__generic-attachment__icon__extension">
|
||||||
|
{extension}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{isDangerous ? (
|
||||||
|
<div className="module-message__generic-attachment__icon-dangerous-container">
|
||||||
|
<div className="module-message__generic-attachment__icon-dangerous" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
@ -734,9 +743,16 @@ export class Message extends React.Component<Props, State> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fileName = attachment && attachment.fileName;
|
||||||
|
const isDangerous = isFileDangerous(fileName || '');
|
||||||
|
|
||||||
const downloadButton = attachment ? (
|
const downloadButton = attachment ? (
|
||||||
<div
|
<div
|
||||||
onClick={onDownload}
|
onClick={() => {
|
||||||
|
if (onDownload) {
|
||||||
|
onDownload(isDangerous);
|
||||||
|
}
|
||||||
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'module-message__buttons__download',
|
'module-message__buttons__download',
|
||||||
|
|
|
@ -1,6 +1,13 @@
|
||||||
import * as GoogleChrome from './GoogleChrome';
|
import * as GoogleChrome from './GoogleChrome';
|
||||||
import { arrayBufferToObjectURL } from './arrayBufferToObjectURL';
|
import { arrayBufferToObjectURL } from './arrayBufferToObjectURL';
|
||||||
|
import { isFileDangerous } from './isFileDangerous';
|
||||||
import { missingCaseError } from './missingCaseError';
|
import { missingCaseError } from './missingCaseError';
|
||||||
import { migrateColor } from './migrateColor';
|
import { migrateColor } from './migrateColor';
|
||||||
|
|
||||||
export { arrayBufferToObjectURL, GoogleChrome, missingCaseError, migrateColor };
|
export {
|
||||||
|
arrayBufferToObjectURL,
|
||||||
|
GoogleChrome,
|
||||||
|
isFileDangerous,
|
||||||
|
migrateColor,
|
||||||
|
missingCaseError,
|
||||||
|
};
|
||||||
|
|
6
ts/util/isFileDangerous.ts
Normal file
6
ts/util/isFileDangerous.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
// tslint:disable-next-line max-line-length
|
||||||
|
const DANGEROUS_FILE_TYPES = /\.(ADE|ADP|APK|BAT|CHM|CMD|COM|CPL|DLL|DMG|EXE|HTA|INS|ISP|JAR|JS|JSE|LIB|LNK|MDE|MSC|MSI|MSP|MST|NSH|PIF|SCR|SCT|SHB|SYS|VB|VBE|VBS|VXD|WSC|WSF|WSH|CAB)$/i;
|
||||||
|
|
||||||
|
export function isFileDangerous(fileName: string): boolean {
|
||||||
|
return DANGEROUS_FILE_TYPES.test(fileName);
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue