Improve drive management
This commit is contained in:
		
							parent
							
								
									9403ee6495
								
							
						
					
					
						commit
						72fb23f4d5
					
				
					 14 changed files with 152 additions and 54 deletions
				
			
		|  | @ -1417,6 +1417,9 @@ admin/views/drive.vue: | ||||||
|   unmark-as-sensitive: "閲覧注意を解除" |   unmark-as-sensitive: "閲覧注意を解除" | ||||||
|   marked-as-sensitive: "閲覧注意に設定しました" |   marked-as-sensitive: "閲覧注意に設定しました" | ||||||
|   unmarked-as-sensitive: "閲覧注意を解除しました" |   unmarked-as-sensitive: "閲覧注意を解除しました" | ||||||
|  |   clean-remote-files: "リモートファイルのキャッシュを削除" | ||||||
|  |   clean-remote-files-are-you-sure: "すべてのリモートファイルのキャッシュを削除してもよろしいですか?" | ||||||
|  |   clean-up: "クリーンアップ" | ||||||
| 
 | 
 | ||||||
| admin/views/users.vue: | admin/views/users.vue: | ||||||
|   operation: "操作" |   operation: "操作" | ||||||
|  |  | ||||||
|  | @ -14,6 +14,10 @@ | ||||||
| 			<ui-button @click="show()"><fa :icon="faSearch"/> {{ $t('lookup') }}</ui-button> | 			<ui-button @click="show()"><fa :icon="faSearch"/> {{ $t('lookup') }}</ui-button> | ||||||
| 			<ui-textarea v-if="file" :value="file | json5" readonly tall style="margin-top:16px;"></ui-textarea> | 			<ui-textarea v-if="file" :value="file | json5" readonly tall style="margin-top:16px;"></ui-textarea> | ||||||
| 		</section> | 		</section> | ||||||
|  | 		<section> | ||||||
|  | 			<ui-button @click="cleanUp()"><fa :icon="faTrashAlt"/> {{ $t('clean-up') }}</ui-button> | ||||||
|  | 			<ui-button @click="cleanRemoteFiles()"><fa :icon="faTrashAlt"/> {{ $t('clean-remote-files') }}</ui-button> | ||||||
|  | 		</section> | ||||||
| 	</ui-card> | 	</ui-card> | ||||||
| 
 | 
 | ||||||
| 	<ui-card> | 	<ui-card> | ||||||
|  | @ -227,6 +231,29 @@ export default Vue.extend({ | ||||||
| 				}); | 				}); | ||||||
| 			}); | 			}); | ||||||
| 		}, | 		}, | ||||||
|  | 
 | ||||||
|  | 		cleanRemoteFiles() { | ||||||
|  | 			this.$root.dialog({ | ||||||
|  | 				type: 'warning', | ||||||
|  | 				text: this.$t('clean-remote-files-are-you-sure'), | ||||||
|  | 				showCancelButton: true | ||||||
|  | 			}).then(({ canceled }) => { | ||||||
|  | 				if (canceled) return; | ||||||
|  | 				this.$root.api('admin/drive/clean-remote-files'); | ||||||
|  | 				this.$root.dialog({ | ||||||
|  | 					type: 'success', | ||||||
|  | 					splash: true | ||||||
|  | 				}); | ||||||
|  | 			}); | ||||||
|  | 		}, | ||||||
|  | 
 | ||||||
|  | 		cleanUp() { | ||||||
|  | 			this.$root.api('admin/drive/cleanup'); | ||||||
|  | 			this.$root.dialog({ | ||||||
|  | 				type: 'success', | ||||||
|  | 				splash: true | ||||||
|  | 			}); | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| }); | }); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|  | @ -8,6 +8,7 @@ import { program } from '../argv'; | ||||||
| import processDeliver from './processors/deliver'; | import processDeliver from './processors/deliver'; | ||||||
| import processInbox from './processors/inbox'; | import processInbox from './processors/inbox'; | ||||||
| import processDb from './processors/db'; | import processDb from './processors/db'; | ||||||
|  | import procesObjectStorage from './processors/object-storage'; | ||||||
| import { queueLogger } from './logger'; | import { queueLogger } from './logger'; | ||||||
| import { DriveFile } from '../models/entities/drive-file'; | import { DriveFile } from '../models/entities/drive-file'; | ||||||
| 
 | 
 | ||||||
|  | @ -34,9 +35,12 @@ function renderError(e: Error): any { | ||||||
| export const deliverQueue = initializeQueue('deliver'); | export const deliverQueue = initializeQueue('deliver'); | ||||||
| export const inboxQueue = initializeQueue('inbox'); | export const inboxQueue = initializeQueue('inbox'); | ||||||
| export const dbQueue = initializeQueue('db'); | export const dbQueue = initializeQueue('db'); | ||||||
|  | export const objectStorageQueue = initializeQueue('objectStorage'); | ||||||
| 
 | 
 | ||||||
| const deliverLogger = queueLogger.createSubLogger('deliver'); | const deliverLogger = queueLogger.createSubLogger('deliver'); | ||||||
| const inboxLogger = queueLogger.createSubLogger('inbox'); | const inboxLogger = queueLogger.createSubLogger('inbox'); | ||||||
|  | const dbLogger = queueLogger.createSubLogger('db'); | ||||||
|  | const objectStorageLogger = queueLogger.createSubLogger('objectStorage'); | ||||||
| 
 | 
 | ||||||
| deliverQueue | deliverQueue | ||||||
| 	.on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`)) | 	.on('waiting', (jobId) => deliverLogger.debug(`waiting id=${jobId}`)) | ||||||
|  | @ -54,6 +58,22 @@ inboxQueue | ||||||
| 	.on('error', (job: any, err: Error) => inboxLogger.error(`error ${err}`, { job, e: renderError(err) })) | 	.on('error', (job: any, err: Error) => inboxLogger.error(`error ${err}`, { job, e: renderError(err) })) | ||||||
| 	.on('stalled', (job) => inboxLogger.warn(`stalled id=${job.id} activity=${job.data.activity ? job.data.activity.id : 'none'}`)); | 	.on('stalled', (job) => inboxLogger.warn(`stalled id=${job.id} activity=${job.data.activity ? job.data.activity.id : 'none'}`)); | ||||||
| 
 | 
 | ||||||
|  | dbQueue | ||||||
|  | 	.on('waiting', (jobId) => dbLogger.debug(`waiting id=${jobId}`)) | ||||||
|  | 	.on('active', (job) => dbLogger.debug(`active id=${job.id}`)) | ||||||
|  | 	.on('completed', (job, result) => dbLogger.debug(`completed(${result}) id=${job.id}`)) | ||||||
|  | 	.on('failed', (job, err) => dbLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) | ||||||
|  | 	.on('error', (job: any, err: Error) => dbLogger.error(`error ${err}`, { job, e: renderError(err) })) | ||||||
|  | 	.on('stalled', (job) => dbLogger.warn(`stalled id=${job.id}`)); | ||||||
|  | 
 | ||||||
|  | objectStorageQueue | ||||||
|  | 	.on('waiting', (jobId) => objectStorageLogger.debug(`waiting id=${jobId}`)) | ||||||
|  | 	.on('active', (job) => objectStorageLogger.debug(`active id=${job.id}`)) | ||||||
|  | 	.on('completed', (job, result) => objectStorageLogger.debug(`completed(${result}) id=${job.id}`)) | ||||||
|  | 	.on('failed', (job, err) => objectStorageLogger.warn(`failed(${err}) id=${job.id}`, { job, e: renderError(err) })) | ||||||
|  | 	.on('error', (job: any, err: Error) => objectStorageLogger.error(`error ${err}`, { job, e: renderError(err) })) | ||||||
|  | 	.on('stalled', (job) => objectStorageLogger.warn(`stalled id=${job.id}`)); | ||||||
|  | 
 | ||||||
| export function deliver(user: ILocalUser, content: any, to: any) { | export function deliver(user: ILocalUser, content: any, to: any) { | ||||||
| 	if (content == null) return null; | 	if (content == null) return null; | ||||||
| 
 | 
 | ||||||
|  | @ -165,11 +185,21 @@ export function createImportUserListsJob(user: ILocalUser, fileId: DriveFile['id | ||||||
| 	}); | 	}); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | export function createDeleteObjectStorageFileJob(key: string) { | ||||||
|  | 	return objectStorageQueue.add('deleteFile', { | ||||||
|  | 		key: key | ||||||
|  | 	}, { | ||||||
|  | 		removeOnComplete: true, | ||||||
|  | 		removeOnFail: true | ||||||
|  | 	}); | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export default function() { | export default function() { | ||||||
| 	if (!program.onlyServer) { | 	if (!program.onlyServer) { | ||||||
| 		deliverQueue.process(128, processDeliver); | 		deliverQueue.process(128, processDeliver); | ||||||
| 		inboxQueue.process(128, processInbox); | 		inboxQueue.process(128, processInbox); | ||||||
| 		processDb(dbQueue); | 		processDb(dbQueue); | ||||||
|  | 		procesObjectStorage(objectStorageQueue); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,7 +1,7 @@ | ||||||
| import * as Bull from 'bull'; | import * as Bull from 'bull'; | ||||||
| 
 | 
 | ||||||
| import { queueLogger } from '../../logger'; | import { queueLogger } from '../../logger'; | ||||||
| import deleteFile from '../../../services/drive/delete-file'; | import { deleteFile } from '../../../services/drive/delete-file'; | ||||||
| import { Users, DriveFiles } from '../../../models'; | import { Users, DriveFiles } from '../../../models'; | ||||||
| import { MoreThan } from 'typeorm'; | import { MoreThan } from 'typeorm'; | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										22
									
								
								src/queue/processors/object-storage/delete-file.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/queue/processors/object-storage/delete-file.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,22 @@ | ||||||
|  | import * as Bull from 'bull'; | ||||||
|  | import * as Minio from 'minio'; | ||||||
|  | import { fetchMeta } from '../../../misc/fetch-meta'; | ||||||
|  | 
 | ||||||
|  | export default async (job: Bull.Job) => { | ||||||
|  | 	const meta = await fetchMeta(); | ||||||
|  | 
 | ||||||
|  | 	const minio = new Minio.Client({ | ||||||
|  | 		endPoint: meta.objectStorageEndpoint!, | ||||||
|  | 		region: meta.objectStorageRegion ? meta.objectStorageRegion : undefined, | ||||||
|  | 		port: meta.objectStoragePort ? meta.objectStoragePort : undefined, | ||||||
|  | 		useSSL: meta.objectStorageUseSSL, | ||||||
|  | 		accessKey: meta.objectStorageAccessKey!, | ||||||
|  | 		secretKey: meta.objectStorageSecretKey!, | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	const key: string = job.data.key; | ||||||
|  | 
 | ||||||
|  | 	await minio.removeObject(meta.objectStorageBucket!, key); | ||||||
|  | 
 | ||||||
|  | 	return 'Success'; | ||||||
|  | }; | ||||||
							
								
								
									
										12
									
								
								src/queue/processors/object-storage/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/queue/processors/object-storage/index.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,12 @@ | ||||||
|  | import * as Bull from 'bull'; | ||||||
|  | import deleteFile from './delete-file'; | ||||||
|  | 
 | ||||||
|  | const jobs = { | ||||||
|  | 	deleteFile, | ||||||
|  | } as any; | ||||||
|  | 
 | ||||||
|  | export default function(q: Bull.Queue) { | ||||||
|  | 	for (const [k, v] of Object.entries(jobs)) { | ||||||
|  | 		q.process(k, v as any); | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| import $ from 'cafy'; | import $ from 'cafy'; | ||||||
| import define from '../../define'; | import define from '../../define'; | ||||||
| import del from '../../../../services/drive/delete-file'; | import { deleteFile } from '../../../../services/drive/delete-file'; | ||||||
| import { DriveFiles } from '../../../../models'; | import { DriveFiles } from '../../../../models'; | ||||||
| import { ID } from '../../../../misc/cafy-id'; | import { ID } from '../../../../misc/cafy-id'; | ||||||
| 
 | 
 | ||||||
|  | @ -27,6 +27,6 @@ export default define(meta, async (ps, me) => { | ||||||
| 	}); | 	}); | ||||||
| 
 | 
 | ||||||
| 	for (const file of files) { | 	for (const file of files) { | ||||||
| 		del(file); | 		deleteFile(file); | ||||||
| 	} | 	} | ||||||
| }); | }); | ||||||
|  |  | ||||||
							
								
								
									
										21
									
								
								src/server/api/endpoints/admin/drive/clean-remote-files.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/server/api/endpoints/admin/drive/clean-remote-files.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | ||||||
|  | import { Not, IsNull } from 'typeorm'; | ||||||
|  | import define from '../../../define'; | ||||||
|  | import { deleteFile } from '../../../../../services/drive/delete-file'; | ||||||
|  | import { DriveFiles } from '../../../../../models'; | ||||||
|  | 
 | ||||||
|  | export const meta = { | ||||||
|  | 	tags: ['admin'], | ||||||
|  | 
 | ||||||
|  | 	requireCredential: true, | ||||||
|  | 	requireModerator: true, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default define(meta, async (ps, me) => { | ||||||
|  | 	const files = await DriveFiles.find({ | ||||||
|  | 		userHost: Not(IsNull()) | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	for (const file of files) { | ||||||
|  | 		deleteFile(file, true); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
							
								
								
									
										21
									
								
								src/server/api/endpoints/admin/drive/cleanup.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/server/api/endpoints/admin/drive/cleanup.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,21 @@ | ||||||
|  | import { IsNull } from 'typeorm'; | ||||||
|  | import define from '../../../define'; | ||||||
|  | import { deleteFile } from '../../../../../services/drive/delete-file'; | ||||||
|  | import { DriveFiles } from '../../../../../models'; | ||||||
|  | 
 | ||||||
|  | export const meta = { | ||||||
|  | 	tags: ['admin'], | ||||||
|  | 
 | ||||||
|  | 	requireCredential: true, | ||||||
|  | 	requireModerator: true, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default define(meta, async (ps, me) => { | ||||||
|  | 	const files = await DriveFiles.find({ | ||||||
|  | 		userId: IsNull() | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	for (const file of files) { | ||||||
|  | 		deleteFile(file); | ||||||
|  | 	} | ||||||
|  | }); | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| import $ from 'cafy'; | import $ from 'cafy'; | ||||||
| import define from '../../../define'; | import define from '../../../define'; | ||||||
| import del from '../../../../../services/drive/delete-file'; | import { deleteFile } from '../../../../../services/drive/delete-file'; | ||||||
| import { DriveFiles } from '../../../../../models'; | import { DriveFiles } from '../../../../../models'; | ||||||
| 
 | 
 | ||||||
| export const meta = { | export const meta = { | ||||||
|  | @ -22,6 +22,6 @@ export default define(meta, async (ps, me) => { | ||||||
| 	}); | 	}); | ||||||
| 
 | 
 | ||||||
| 	for (const file of files) { | 	for (const file of files) { | ||||||
| 		del(file); | 		deleteFile(file); | ||||||
| 	} | 	} | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| import $ from 'cafy'; | import $ from 'cafy'; | ||||||
| import { ID } from '../../../../../misc/cafy-id'; | import { ID } from '../../../../../misc/cafy-id'; | ||||||
| import del from '../../../../../services/drive/delete-file'; | import { deleteFile } from '../../../../../services/drive/delete-file'; | ||||||
| import { publishDriveStream } from '../../../../../services/stream'; | import { publishDriveStream } from '../../../../../services/stream'; | ||||||
| import define from '../../../define'; | import define from '../../../define'; | ||||||
| import { ApiError } from '../../../error'; | import { ApiError } from '../../../error'; | ||||||
|  | @ -57,7 +57,7 @@ export default define(meta, async (ps, user) => { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Delete
 | 	// Delete
 | ||||||
| 	await del(file); | 	await deleteFile(file); | ||||||
| 
 | 
 | ||||||
| 	// Publish fileDeleted event
 | 	// Publish fileDeleted event
 | ||||||
| 	publishDriveStream(user.id, 'fileDeleted', file.id); | 	publishDriveStream(user.id, 'fileDeleted', file.id); | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ import * as uuid from 'uuid'; | ||||||
| import * as sharp from 'sharp'; | import * as sharp from 'sharp'; | ||||||
| 
 | 
 | ||||||
| import { publishMainStream, publishDriveStream } from '../stream'; | import { publishMainStream, publishDriveStream } from '../stream'; | ||||||
| import delFile from './delete-file'; | import { deleteFile } from './delete-file'; | ||||||
| import { fetchMeta } from '../../misc/fetch-meta'; | import { fetchMeta } from '../../misc/fetch-meta'; | ||||||
| import { GenerateVideoThumbnail } from './generate-video-thumbnail'; | import { GenerateVideoThumbnail } from './generate-video-thumbnail'; | ||||||
| import { driveLogger } from './logger'; | import { driveLogger } from './logger'; | ||||||
|  | @ -233,7 +233,7 @@ async function deleteOldFile(user: IRemoteUser) { | ||||||
| 	const oldFile = await q.getOne(); | 	const oldFile = await q.getOne(); | ||||||
| 
 | 
 | ||||||
| 	if (oldFile) { | 	if (oldFile) { | ||||||
| 		delFile(oldFile, true); | 		deleteFile(oldFile, true); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,11 +1,10 @@ | ||||||
| import * as Minio from 'minio'; |  | ||||||
| import { DriveFile } from '../../models/entities/drive-file'; | import { DriveFile } from '../../models/entities/drive-file'; | ||||||
| import { InternalStorage } from './internal-storage'; | import { InternalStorage } from './internal-storage'; | ||||||
| import { DriveFiles, Instances, Notes } from '../../models'; | import { DriveFiles, Instances, Notes } from '../../models'; | ||||||
| import { driveChart, perUserDriveChart, instanceChart } from '../chart'; | import { driveChart, perUserDriveChart, instanceChart } from '../chart'; | ||||||
| import { fetchMeta } from '../../misc/fetch-meta'; | import { createDeleteObjectStorageFileJob } from '../../queue'; | ||||||
| 
 | 
 | ||||||
| export default async function(file: DriveFile, isExpired = false) { | export async function deleteFile(file: DriveFile, isExpired = false) { | ||||||
| 	if (file.storedInternal) { | 	if (file.storedInternal) { | ||||||
| 		InternalStorage.del(file.accessKey!); | 		InternalStorage.del(file.accessKey!); | ||||||
| 
 | 
 | ||||||
|  | @ -17,25 +16,14 @@ export default async function(file: DriveFile, isExpired = false) { | ||||||
| 			InternalStorage.del(file.webpublicAccessKey!); | 			InternalStorage.del(file.webpublicAccessKey!); | ||||||
| 		} | 		} | ||||||
| 	} else if (!file.isLink) { | 	} else if (!file.isLink) { | ||||||
| 		const meta = await fetchMeta(); | 		createDeleteObjectStorageFileJob(file.accessKey!); | ||||||
| 
 |  | ||||||
| 		const minio = new Minio.Client({ |  | ||||||
| 			endPoint: meta.objectStorageEndpoint!, |  | ||||||
| 			region: meta.objectStorageRegion ? meta.objectStorageRegion : undefined, |  | ||||||
| 			port: meta.objectStoragePort ? meta.objectStoragePort : undefined, |  | ||||||
| 			useSSL: meta.objectStorageUseSSL, |  | ||||||
| 			accessKey: meta.objectStorageAccessKey!, |  | ||||||
| 			secretKey: meta.objectStorageSecretKey!, |  | ||||||
| 		}); |  | ||||||
| 
 |  | ||||||
| 		await minio.removeObject(meta.objectStorageBucket!, file.accessKey!); |  | ||||||
| 
 | 
 | ||||||
| 		if (file.thumbnailUrl) { | 		if (file.thumbnailUrl) { | ||||||
| 			await minio.removeObject(meta.objectStorageBucket!, file.thumbnailAccessKey!); | 			createDeleteObjectStorageFileJob(file.thumbnailAccessKey!); | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		if (file.webpublicUrl) { | 		if (file.webpublicUrl) { | ||||||
| 			await minio.removeObject(meta.objectStorageBucket!, file.webpublicAccessKey!); | 			createDeleteObjectStorageFileJob(file.webpublicAccessKey!); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | @ -44,8 +32,8 @@ export default async function(file: DriveFile, isExpired = false) { | ||||||
| 		DriveFiles.update(file.id, { | 		DriveFiles.update(file.id, { | ||||||
| 			isLink: true, | 			isLink: true, | ||||||
| 			url: file.uri, | 			url: file.uri, | ||||||
| 			thumbnailUrl: null, | 			thumbnailUrl: file.uri, | ||||||
| 			webpublicUrl: null | 			webpublicUrl: file.uri | ||||||
| 		}); | 		}); | ||||||
| 	} else { | 	} else { | ||||||
| 		DriveFiles.delete(file.id); | 		DriveFiles.delete(file.id); | ||||||
|  |  | ||||||
|  | @ -1,26 +0,0 @@ | ||||||
| import * as promiseLimit from 'promise-limit'; |  | ||||||
| import del from '../services/drive/delete-file'; |  | ||||||
| import { DriveFiles } from '../models'; |  | ||||||
| import { Not, IsNull } from 'typeorm'; |  | ||||||
| import { DriveFile } from '../models/entities/drive-file'; |  | ||||||
| import { ensure } from '../prelude/ensure'; |  | ||||||
| 
 |  | ||||||
| const limit = promiseLimit(16); |  | ||||||
| 
 |  | ||||||
| DriveFiles.find({ |  | ||||||
| 	userHost: Not(IsNull()) |  | ||||||
| }).then(async files => { |  | ||||||
| 	console.log(`there is ${files.length} files`); |  | ||||||
| 
 |  | ||||||
| 	await Promise.all(files.map(file => limit(() => job(file)))); |  | ||||||
| 
 |  | ||||||
| 	console.log('ALL DONE'); |  | ||||||
| }); |  | ||||||
| 
 |  | ||||||
| async function job(file: DriveFile): Promise<any> { |  | ||||||
| 	file = await DriveFiles.findOne(file.id).then(ensure); |  | ||||||
| 
 |  | ||||||
| 	await del(file, true); |  | ||||||
| 
 |  | ||||||
| 	console.log('done', file.id); |  | ||||||
| } |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue