mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-05 13:39:24 +08:00
Merge branch 'main' into next
This commit is contained in:
commit
d76ca21d29
@ -101,7 +101,7 @@ export class FileCollectionTemplate extends CollectionTemplate {
|
|||||||
// 文件的可访问地址
|
// 文件的可访问地址
|
||||||
{
|
{
|
||||||
interface: 'url',
|
interface: 'url',
|
||||||
type: 'string',
|
type: 'text',
|
||||||
name: 'url',
|
name: 'url',
|
||||||
deletable: false,
|
deletable: false,
|
||||||
length: 1024,
|
length: 1024,
|
||||||
|
@ -82,10 +82,10 @@ describe('action', () => {
|
|||||||
|
|
||||||
// 关联的存储引擎是否正确
|
// 关联的存储引擎是否正确
|
||||||
const storage = await attachment.getStorage();
|
const storage = await attachment.getStorage();
|
||||||
expect(storage).toMatchObject({
|
expect(storage.get()).toMatchObject({
|
||||||
type: 'local',
|
type: STORAGE_TYPE_LOCAL,
|
||||||
options: { documentRoot: LOCAL_STORAGE_DEST },
|
options: { documentRoot: LOCAL_STORAGE_DEST },
|
||||||
rules: {},
|
rules: { size: FILE_SIZE_LIMIT_DEFAULT },
|
||||||
path: '',
|
path: '',
|
||||||
baseUrl: DEFAULT_LOCAL_BASE_URL,
|
baseUrl: DEFAULT_LOCAL_BASE_URL,
|
||||||
default: true,
|
default: true,
|
||||||
@ -94,7 +94,7 @@ describe('action', () => {
|
|||||||
const { documentRoot = 'storage/uploads' } = storage.options || {};
|
const { documentRoot = 'storage/uploads' } = storage.options || {};
|
||||||
const destPath = path.resolve(
|
const destPath = path.resolve(
|
||||||
path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot),
|
path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot),
|
||||||
storage.path,
|
storage.path || '',
|
||||||
);
|
);
|
||||||
const file = await fs.readFile(`${destPath}/${attachment.filename}`);
|
const file = await fs.readFile(`${destPath}/${attachment.filename}`);
|
||||||
// 文件是否保存到指定路径
|
// 文件是否保存到指定路径
|
||||||
@ -205,6 +205,51 @@ describe('action', () => {
|
|||||||
const content = await agent.get(url);
|
const content = await agent.get(url);
|
||||||
expect(content.text.includes('Hello world!')).toBe(true);
|
expect(content.text.includes('Hello world!')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('path longer than 255', async () => {
|
||||||
|
const BASE_URL = `http://localhost:${APP_PORT}/storage/uploads/another`;
|
||||||
|
const urlPath =
|
||||||
|
'extreme-test/max-long-path-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890-1234567890';
|
||||||
|
|
||||||
|
// 动态添加 storage
|
||||||
|
const storage = await StorageRepo.create({
|
||||||
|
values: {
|
||||||
|
name: 'local_private',
|
||||||
|
type: STORAGE_TYPE_LOCAL,
|
||||||
|
rules: {
|
||||||
|
mimetype: ['text/*'],
|
||||||
|
},
|
||||||
|
path: urlPath,
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
options: {
|
||||||
|
documentRoot: 'storage/uploads/another',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
db.collection({
|
||||||
|
name: 'customers',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'file',
|
||||||
|
type: 'belongsTo',
|
||||||
|
target: 'attachments',
|
||||||
|
storage: storage.name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { body } = await agent.resource('attachments').create({
|
||||||
|
attachmentField: 'customers.file',
|
||||||
|
file: path.resolve(__dirname, './files/text.txt'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 文件的 url 是否正常生成
|
||||||
|
expect(body.data.url).toBe(`${BASE_URL}/${urlPath}/${body.data.filename}`);
|
||||||
|
const url = body.data.url.replace(`http://localhost:${APP_PORT}`, '');
|
||||||
|
const content = await agent.get(url);
|
||||||
|
expect(content.text.includes('Hello world!')).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -274,7 +319,7 @@ describe('action', () => {
|
|||||||
const { documentRoot = 'storage/uploads' } = storage.options || {};
|
const { documentRoot = 'storage/uploads' } = storage.options || {};
|
||||||
const destPath = path.resolve(
|
const destPath = path.resolve(
|
||||||
path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot),
|
path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot),
|
||||||
storage.path,
|
storage.path || '',
|
||||||
);
|
);
|
||||||
const file = await fs.stat(path.join(destPath, attachment.filename));
|
const file = await fs.stat(path.join(destPath, attachment.filename));
|
||||||
expect(file).toBeTruthy();
|
expect(file).toBeTruthy();
|
||||||
@ -300,7 +345,7 @@ describe('action', () => {
|
|||||||
const { documentRoot = path.join('storage', 'uploads') } = storage.options || {};
|
const { documentRoot = path.join('storage', 'uploads') } = storage.options || {};
|
||||||
const destPath = path.resolve(
|
const destPath = path.resolve(
|
||||||
path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot),
|
path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot),
|
||||||
storage.path,
|
storage.path || '',
|
||||||
);
|
);
|
||||||
const file = await fs.stat(path.join(destPath, attachment.filename));
|
const file = await fs.stat(path.join(destPath, attachment.filename));
|
||||||
expect(file).toBeTruthy();
|
expect(file).toBeTruthy();
|
||||||
@ -332,7 +377,7 @@ describe('action', () => {
|
|||||||
const { documentRoot = path.join('storage', 'uploads') } = storage.options || {};
|
const { documentRoot = path.join('storage', 'uploads') } = storage.options || {};
|
||||||
const destPath = path.resolve(
|
const destPath = path.resolve(
|
||||||
path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot),
|
path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot),
|
||||||
storage.path,
|
storage.path || '',
|
||||||
);
|
);
|
||||||
const file1 = await fs.stat(path.join(destPath, f1.data.filename));
|
const file1 = await fs.stat(path.join(destPath, f1.data.filename));
|
||||||
expect(file1).toBeTruthy();
|
expect(file1).toBeTruthy();
|
||||||
@ -361,7 +406,7 @@ describe('action', () => {
|
|||||||
const { documentRoot = path.join('storage', 'uploads') } = storage.options || {};
|
const { documentRoot = path.join('storage', 'uploads') } = storage.options || {};
|
||||||
const destPath = path.resolve(
|
const destPath = path.resolve(
|
||||||
path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot),
|
path.isAbsolute(documentRoot) ? documentRoot : path.join(process.cwd(), documentRoot),
|
||||||
storage.path,
|
storage.path || '',
|
||||||
);
|
);
|
||||||
const filePath = path.join(destPath, attachment.filename);
|
const filePath = path.join(destPath, attachment.filename);
|
||||||
const file = await fs.stat(filePath);
|
const file = await fs.stat(filePath);
|
||||||
|
@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* This file is part of the NocoBase (R) project.
|
||||||
|
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||||
|
* Authors: NocoBase Team.
|
||||||
|
*
|
||||||
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||||
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MockServer, createMockServer } from '@nocobase/test';
|
||||||
|
import Migration from '../../migrations/20250225112112-increase-url-length';
|
||||||
|
import PluginCollectionManagerServer from '../../server';
|
||||||
|
|
||||||
|
describe('file-manager > migrations', () => {
|
||||||
|
let app: MockServer;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
app = await createMockServer({
|
||||||
|
plugins: ['nocobase'],
|
||||||
|
});
|
||||||
|
await app.version.update('1.5.13');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('migration', async () => {
|
||||||
|
await app.db.getRepository('collections').create({
|
||||||
|
values: {
|
||||||
|
name: 'foo',
|
||||||
|
template: 'file',
|
||||||
|
createdBy: true,
|
||||||
|
updatedBy: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
interface: 'input',
|
||||||
|
type: 'string',
|
||||||
|
name: 'title',
|
||||||
|
deletable: false,
|
||||||
|
uiSchema: {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t("Title")}}`,
|
||||||
|
'x-component': 'Input',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// '系统文件名(含扩展名)',
|
||||||
|
{
|
||||||
|
interface: 'input',
|
||||||
|
type: 'string',
|
||||||
|
name: 'filename',
|
||||||
|
deletable: false,
|
||||||
|
uiSchema: {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t("File name")}}`,
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-read-pretty': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// '扩展名(含“.”)',
|
||||||
|
{
|
||||||
|
interface: 'input',
|
||||||
|
type: 'string',
|
||||||
|
name: 'extname',
|
||||||
|
deletable: false,
|
||||||
|
uiSchema: {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t("Extension name")}}`,
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-read-pretty': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// '文件体积(字节)',
|
||||||
|
{
|
||||||
|
interface: 'integer',
|
||||||
|
type: 'integer',
|
||||||
|
name: 'size',
|
||||||
|
deletable: false,
|
||||||
|
uiSchema: {
|
||||||
|
type: 'number',
|
||||||
|
title: `{{t("Size")}}`,
|
||||||
|
'x-component': 'InputNumber',
|
||||||
|
'x-read-pretty': true,
|
||||||
|
'x-component-props': {
|
||||||
|
stringMode: true,
|
||||||
|
step: '0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
interface: 'input',
|
||||||
|
type: 'string',
|
||||||
|
name: 'mimetype',
|
||||||
|
deletable: false,
|
||||||
|
uiSchema: {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t("MIME type")}}`,
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-read-pretty': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// '相对路径(含“/”前缀)',
|
||||||
|
{
|
||||||
|
interface: 'input',
|
||||||
|
type: 'string',
|
||||||
|
name: 'path',
|
||||||
|
deletable: false,
|
||||||
|
uiSchema: {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t("Path")}}`,
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-read-pretty': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 文件的可访问地址
|
||||||
|
{
|
||||||
|
interface: 'url',
|
||||||
|
type: 'string',
|
||||||
|
name: 'url',
|
||||||
|
deletable: false,
|
||||||
|
uiSchema: {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t("URL")}}`,
|
||||||
|
'x-component': 'Input.URL',
|
||||||
|
'x-read-pretty': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 用于预览
|
||||||
|
{
|
||||||
|
interface: 'url',
|
||||||
|
type: 'string',
|
||||||
|
name: 'preview',
|
||||||
|
field: 'url', // 直接引用 url 字段
|
||||||
|
deletable: false,
|
||||||
|
uiSchema: {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t("Preview")}}`,
|
||||||
|
'x-component': 'Preview',
|
||||||
|
'x-read-pretty': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
comment: '存储引擎',
|
||||||
|
type: 'belongsTo',
|
||||||
|
name: 'storage',
|
||||||
|
target: 'storages',
|
||||||
|
foreignKey: 'storageId',
|
||||||
|
deletable: false,
|
||||||
|
uiSchema: {
|
||||||
|
type: 'string',
|
||||||
|
title: `{{t("Storage")}}`,
|
||||||
|
'x-component': 'Input',
|
||||||
|
'x-read-pretty': true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// '其他文件信息(如图片的宽高)',
|
||||||
|
{
|
||||||
|
type: 'jsonb',
|
||||||
|
name: 'meta',
|
||||||
|
deletable: false,
|
||||||
|
defaultValue: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const migration = new Migration({
|
||||||
|
db: app.db,
|
||||||
|
// @ts-ignore
|
||||||
|
app: app,
|
||||||
|
plugin: app.pm.get(PluginCollectionManagerServer),
|
||||||
|
});
|
||||||
|
await migration.up();
|
||||||
|
const fileURL = await app.db.getRepository('fields').findOne({
|
||||||
|
filter: {
|
||||||
|
collectionName: 'foo',
|
||||||
|
name: 'url',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fileURL.type).toBe('text');
|
||||||
|
const filePath = await app.db.getRepository('fields').findOne({
|
||||||
|
filter: {
|
||||||
|
collectionName: 'foo',
|
||||||
|
name: 'path',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(filePath.type).toBe('text');
|
||||||
|
|
||||||
|
const storageCollection = app.db.getCollection('storages');
|
||||||
|
const pathField = storageCollection.getField('path');
|
||||||
|
expect(pathField.type).toBe('text');
|
||||||
|
});
|
||||||
|
});
|
@ -58,7 +58,7 @@ export default defineCollection({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
comment: '相对路径(含“/”前缀)',
|
comment: '相对路径(含“/”前缀)',
|
||||||
type: 'string',
|
type: 'text',
|
||||||
name: 'path',
|
name: 'path',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -69,9 +69,8 @@ export default defineCollection({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
comment: '网络访问地址',
|
comment: '网络访问地址',
|
||||||
type: 'string',
|
type: 'text',
|
||||||
name: 'url',
|
name: 'url',
|
||||||
length: 1024,
|
|
||||||
// formula: '{{ storage.baseUrl }}{{ path }}/{{ filename }}'
|
// formula: '{{ storage.baseUrl }}{{ path }}/{{ filename }}'
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -48,7 +48,7 @@ export default defineCollection({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
comment: '存储相对路径模板',
|
comment: '存储相对路径模板',
|
||||||
type: 'string',
|
type: 'text',
|
||||||
name: 'path',
|
name: 'path',
|
||||||
defaultValue: '',
|
defaultValue: '',
|
||||||
},
|
},
|
||||||
|
@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* This file is part of the NocoBase (R) project.
|
|
||||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
||||||
* Authors: NocoBase Team.
|
|
||||||
*
|
|
||||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
||||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { DataTypes } from 'sequelize';
|
|
||||||
import { Migration } from '@nocobase/server';
|
|
||||||
|
|
||||||
export default class extends Migration {
|
|
||||||
on = 'afterLoad'; // 'beforeLoad' or 'afterLoad'
|
|
||||||
appVersion = '<1.5.14';
|
|
||||||
|
|
||||||
async up() {
|
|
||||||
const queryInterface = this.db.sequelize.getQueryInterface();
|
|
||||||
const CollectionRepo = this.db.getRepository('collections');
|
|
||||||
const FieldRepo = this.db.getRepository('fields');
|
|
||||||
await this.db.sequelize.transaction(async (transaction) => {
|
|
||||||
const collections = await CollectionRepo.find({
|
|
||||||
filter: {
|
|
||||||
'options.template': 'file',
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
collections.push({
|
|
||||||
name: 'attachments',
|
|
||||||
});
|
|
||||||
for (const item of collections) {
|
|
||||||
const collection = this.db.getCollection(item.name) || this.db.collection(item);
|
|
||||||
const tableName = collection.getTableNameWithSchema();
|
|
||||||
await queryInterface.changeColumn(
|
|
||||||
tableName,
|
|
||||||
'url',
|
|
||||||
{
|
|
||||||
type: DataTypes.STRING(1024),
|
|
||||||
},
|
|
||||||
{ transaction },
|
|
||||||
);
|
|
||||||
await FieldRepo.update({
|
|
||||||
filter: {
|
|
||||||
collectionName: item.name,
|
|
||||||
name: 'url',
|
|
||||||
},
|
|
||||||
values: {
|
|
||||||
length: 1024,
|
|
||||||
},
|
|
||||||
transaction,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* This file is part of the NocoBase (R) project.
|
||||||
|
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||||
|
* Authors: NocoBase Team.
|
||||||
|
*
|
||||||
|
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||||
|
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { DataTypes } from 'sequelize';
|
||||||
|
import { Migration } from '@nocobase/server';
|
||||||
|
import { CollectionRepository } from '@nocobase/plugin-data-source-main';
|
||||||
|
|
||||||
|
export default class extends Migration {
|
||||||
|
on = 'afterLoad'; // 'beforeLoad' or 'afterLoad'
|
||||||
|
appVersion = '<1.6.0';
|
||||||
|
|
||||||
|
async up() {
|
||||||
|
const queryInterface = this.db.sequelize.getQueryInterface();
|
||||||
|
const CollectionRepo = this.db.getRepository('collections') as CollectionRepository;
|
||||||
|
const FieldRepo = this.db.getRepository('fields');
|
||||||
|
const StorageRepo = this.db.getRepository('storages');
|
||||||
|
await CollectionRepo.load({
|
||||||
|
filter: {
|
||||||
|
'options.template': 'file',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const collections = Array.from(this.db.collections.values()).filter(
|
||||||
|
(item) => item.name === 'attachments' || item.options.template === 'file',
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.db.sequelize.transaction(async (transaction) => {
|
||||||
|
for (const collection of collections) {
|
||||||
|
const tableName = collection.getTableNameWithSchema();
|
||||||
|
await queryInterface.changeColumn(
|
||||||
|
tableName,
|
||||||
|
'url',
|
||||||
|
{
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
await queryInterface.changeColumn(
|
||||||
|
tableName,
|
||||||
|
'path',
|
||||||
|
{
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await FieldRepo.update({
|
||||||
|
filter: {
|
||||||
|
collectionName: collection.name,
|
||||||
|
name: ['url', 'path'],
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
type: 'text',
|
||||||
|
length: null,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryInterface.changeColumn(
|
||||||
|
this.db.getCollection('storages').getTableNameWithSchema(),
|
||||||
|
'path',
|
||||||
|
{
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
await FieldRepo.update({
|
||||||
|
filter: {
|
||||||
|
collectionName: 'storages',
|
||||||
|
name: 'path',
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
type: 'text',
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -31,6 +31,7 @@ export default class extends StorageType {
|
|||||||
options: {
|
options: {
|
||||||
documentRoot: 'storage/uploads',
|
documentRoot: 'storage/uploads',
|
||||||
},
|
},
|
||||||
|
path: '',
|
||||||
rules: {
|
rules: {
|
||||||
size: FILE_SIZE_LIMIT_DEFAULT,
|
size: FILE_SIZE_LIMIT_DEFAULT,
|
||||||
},
|
},
|
||||||
@ -40,7 +41,7 @@ export default class extends StorageType {
|
|||||||
make() {
|
make() {
|
||||||
return multer.diskStorage({
|
return multer.diskStorage({
|
||||||
destination: (req, file, cb) => {
|
destination: (req, file, cb) => {
|
||||||
const destPath = path.join(getDocumentRoot(this.storage), this.storage.path);
|
const destPath = path.join(getDocumentRoot(this.storage), this.storage.path || '');
|
||||||
mkdirp(destPath, (err: Error | null) => cb(err, destPath));
|
mkdirp(destPath, (err: Error | null) => cb(err, destPath));
|
||||||
},
|
},
|
||||||
filename: getFilename,
|
filename: getFilename,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user